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:
@@ -14,22 +14,21 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/api"
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/config"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
envCfg, err := config.LoadEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
|
||||
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
db, err := store.Open(envCfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
@@ -45,37 +44,43 @@ func main() {
|
||||
}
|
||||
|
||||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||
IssuerURL: cfg.OIDCIssuerURL,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
RequiredRole: cfg.OIDCRequiredRole,
|
||||
IssuerURL: envCfg.OIDCIssuerURL,
|
||||
ClientID: envCfg.OIDCClientID,
|
||||
ClientSecret: envCfg.OIDCClientSecret,
|
||||
RedirectURL: envCfg.OIDCRedirectURL,
|
||||
RequiredRole: envCfg.OIDCRequiredRole,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("oidc: %v", err)
|
||||
}
|
||||
|
||||
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
||||
PythonPath: cfg.GarminPythonPath,
|
||||
TokenStorePath: cfg.GarminTokenStoreRoot,
|
||||
}, appsync.Config{}, authVerifier, api.SessionConfig{
|
||||
Secret: cfg.SessionSecret,
|
||||
server := api.NewServer(
|
||||
db,
|
||||
garmin.NewClient,
|
||||
garmin.ClientConfig{
|
||||
PythonPath: envCfg.PythonPath,
|
||||
TokenStorePath: envCfg.TokenStoreRoot,
|
||||
},
|
||||
garmin.SyncConfig{},
|
||||
authVerifier,
|
||||
api.SessionConfig{
|
||||
Secret: envCfg.SessionSecret,
|
||||
Duration: appCfg.SessionDuration,
|
||||
Secure: cfg.SessionSecure,
|
||||
BackendURL: cfg.BackendURL,
|
||||
FrontendURL: cfg.FrontendURL,
|
||||
Secure: envCfg.SessionSecure,
|
||||
BackendURL: envCfg.BackendURL,
|
||||
FrontendURL: envCfg.FrontendURL,
|
||||
})
|
||||
|
||||
for _, e := range cfg.DisplayEnv() {
|
||||
for _, e := range envCfg.DisplayEnv() {
|
||||
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
|
||||
go func() {
|
||||
log.Printf("geniusrund listening on %s", cfg.Addr)
|
||||
log.Printf("geniusrund listening on %s", envCfg.BackendAddr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -73,8 +72,8 @@ func main() {
|
||||
IsActive: true,
|
||||
}))
|
||||
|
||||
m := &mock.Client{}
|
||||
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
|
||||
m := &garmin.MockClient{}
|
||||
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
|
||||
|
||||
today := time.Now()
|
||||
activityIDs := []int64{}
|
||||
|
||||
BIN
backend/geniusrun.db-shm
Normal file
BIN
backend/geniusrun.db-shm
Normal file
Binary file not shown.
BIN
backend/geniusrun.db-wal
Normal file
BIN
backend/geniusrun.db-wal
Normal file
Binary file not shown.
@@ -15,13 +15,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/log"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func newCtx() context.Context { return context.Background() }
|
||||
@@ -47,9 +45,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, userID
|
||||
}
|
||||
|
||||
@@ -586,7 +584,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -607,9 +605,9 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
}
|
||||
|
||||
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +631,7 @@ func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
||||
t.Fatalf("SetActivityDetails: %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/sync/status", nil)
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -777,7 +775,7 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
t.Fatal("expected a fresh account to report garmin_connected=false")
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -792,18 +790,18 @@ func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||
|
||||
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||
s, _, userID := newTestServer(t)
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -821,17 +819,17 @@ func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
client, err := s.clientFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
mockClient, ok := client.(*garmin.MockClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
@@ -882,9 +880,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type EnvVar struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type appConfigEntry struct {
|
||||
type configEntry struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
@@ -30,13 +30,13 @@ func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||||
return nil, err
|
||||
}
|
||||
registry := config.AppRegistry()
|
||||
app := make([]appConfigEntry, 0, len(registry))
|
||||
app := make([]configEntry, 0, len(registry))
|
||||
for _, k := range registry {
|
||||
value, overridden := overrides[k.Key]
|
||||
if !overridden {
|
||||
value = k.Default
|
||||
}
|
||||
app = append(app, appConfigEntry{
|
||||
app = append(app, configEntry{
|
||||
Key: k.Key, Value: value, Default: k.Default,
|
||||
Overridden: overridden, Description: k.Description,
|
||||
})
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
type configTestResponse struct {
|
||||
@@ -87,8 +84,8 @@ func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||
200
backend/internal/api/garmin.go
Normal file
200
backend/internal/api/garmin.go
Normal 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)
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||||
@@ -150,8 +148,8 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||
if rec.Code != http.StatusForbidden {
|
||||
@@ -196,7 +194,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
client, err := s.garminFor(r.Context(), userID)
|
||||
client, err := s.clientFor(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -98,6 +98,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeGarminClient(userID)
|
||||
s.removeUserClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
applog "geniusrun/backend/internal/log"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -18,14 +19,12 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||||
@@ -34,19 +33,20 @@ import (
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Auth auth.Verifier
|
||||
Session SessionConfig
|
||||
SessionConfig SessionConfig
|
||||
|
||||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||
// fully-resolved per-user Config. Production wiring passes
|
||||
// garmin.NewClient; tests inject a factory returning a shared
|
||||
// *mock.Client (see newTestServer in api_test.go).
|
||||
GarminFactory func(garmin.Config) garmin.Client
|
||||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
||||
GarminFactory func(garmin.ClientConfig) garmin.Client
|
||||
|
||||
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
||||
// (the python interpreter path + the token-store root directory); only
|
||||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||
// garminFor.
|
||||
GarminBase garmin.Config
|
||||
SyncConfig appsync.Config
|
||||
ClientConfig garmin.ClientConfig
|
||||
SyncConfig garmin.SyncConfig
|
||||
|
||||
// EnvVars is the read-only, display-safe environment-configuration
|
||||
// snapshot served by GET /api/config -- built once in main.go from
|
||||
@@ -55,260 +55,36 @@ type Server struct {
|
||||
EnvVars []EnvVar
|
||||
|
||||
mu sync.Mutex
|
||||
userGarmin map[int64]garmin.Client
|
||||
userSync map[int64]*appsync.Service
|
||||
userClient map[int64]garmin.Client
|
||||
userSync map[int64]*garmin.Sync
|
||||
userAuthStatus map[int64]garmin.AuthStatus
|
||||
userAuthMessage map[int64]string
|
||||
userSyncRunning map[int64]bool
|
||||
setupGarmin map[string]*setupSession
|
||||
setupSession map[string]*setupSession
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
||||
return &Server{
|
||||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||||
Auth: authVerifier, Session: session,
|
||||
userGarmin: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*appsync.Service{},
|
||||
DB: db,
|
||||
GarminFactory: garminFactory,
|
||||
ClientConfig: garminBase,
|
||||
SyncConfig: syncConfig,
|
||||
Auth: authVerifier,
|
||||
SessionConfig: sessionConfig,
|
||||
userClient: map[int64]garmin.Client{},
|
||||
userSync: map[int64]*garmin.Sync{},
|
||||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||
userAuthMessage: map[int64]string{},
|
||||
userSyncRunning: map[int64]bool{},
|
||||
setupGarmin: map[string]*setupSession{},
|
||||
setupSession: map[string]*setupSession{},
|
||||
}
|
||||
}
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userGarmin) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||
// an opaque string from the identity provider and using it verbatim in a
|
||||
// filesystem path would be a directory-traversal risk if it ever contained
|
||||
// path separators.
|
||||
func setupTokenStoreDir(root, sub string) string {
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupGarmin[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupGarmin, sub)
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupGarmin[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupGarmin[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupGarmin[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupGarmin[sub]
|
||||
delete(s.setupGarmin, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
// garminFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.GarminBase
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userGarmin[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userGarmin[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.garminFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeGarminClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userGarmin[userID]
|
||||
delete(s.userGarmin, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.GarminBase.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func requestLoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Router builds the HTTP routes.
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLoggingMiddleware)
|
||||
r.Use(loggingMiddleware)
|
||||
r.Use(corsMiddleware)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", s.handleHealth)
|
||||
@@ -319,11 +95,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/session/callback", s.handleSessionCallback)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireSession(s.Session.Secret))
|
||||
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
||||
r.Use(s.resolveUser)
|
||||
|
||||
r.Get("/session/me", s.handleSessionMe)
|
||||
r.Post("/session/logout", s.handleSessionLogout)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/complete", s.handleSetupComplete)
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
@@ -341,17 +118,20 @@ func (s *Server) Router() http.Handler {
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
|
||||
r.Route("/garmin", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", s.handleAuthLogin)
|
||||
r.Post("/mfa", s.handleAuthMFA)
|
||||
r.Get("/status", s.handleAuthStatus)
|
||||
r.Post("/login", s.handleGarminAuthLogin)
|
||||
r.Post("/mfa", s.handleGarminAuthMFA)
|
||||
r.Get("/status", s.handleGarminAuthStatus)
|
||||
})
|
||||
|
||||
r.Route("/sync", func(r chi.Router) {
|
||||
r.Post("/run", s.handleSyncRun)
|
||||
r.Post("/reset", s.handleSyncReset)
|
||||
r.Get("/runs", s.handleSyncRuns)
|
||||
r.Get("/status", s.handleSyncStatus)
|
||||
r.Post("/run", s.handleGarminSyncRun)
|
||||
r.Post("/reset", s.handleGarminSyncReset)
|
||||
r.Get("/runs", s.handleGarminSyncRuns)
|
||||
r.Get("/status", s.handleGarminSyncStatus)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
@@ -379,6 +159,40 @@ func (s *Server) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// loggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// request_id) to the request context, so any downstream call this request
|
||||
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "http request",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||
// this API. Reflecting any origin back is safe even with credentials
|
||||
// enabled: this remains a single-operator app whose real access control is
|
||||
@@ -399,20 +213,89 @@ func corsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// clientFor returns userID's garmin.Client, building and caching it (from
|
||||
// userID's own profile row) on first use.
|
||||
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
profile, err := s.DB.GetProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||
}
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = profile.GarminEmail
|
||||
cfg.GarminPassword = profile.GarminPassword
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.userClient[userID]; ok {
|
||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||
}
|
||||
client := s.GarminFactory(cfg)
|
||||
s.userClient[userID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeUserClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userClient[userID]
|
||||
delete(s.userClient, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.ClientConfig.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
||||
s.mu.Lock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
s.mu.Unlock()
|
||||
return svc, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
client, err := s.clientFor(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if svc, ok := s.userSync[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
||||
s.userSync[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||
@@ -439,3 +322,25 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||
// an opaque string from the identity provider and using it verbatim in a
|
||||
// filesystem path would be a directory-traversal risk if it ever contained
|
||||
// path separators.
|
||||
func setupTokenStoreDir(root, sub string) string {
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
||||
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -58,36 +58,36 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||
if err != nil {
|
||||
log.Printf("session callback: missing txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
||||
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||
if err != nil {
|
||||
log.Printf("session callback: failed to parse txn cookie: %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||
if err != nil {
|
||||
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !result.Authorized {
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, sessionCookie)
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
|
||||
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
@@ -99,8 +99,8 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||
s.removeSetupSession(claims.Sub)
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,11 +7,38 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userClient) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||||
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||||
// row exists yet at this point (see setupSession in server.go).
|
||||
@@ -90,7 +117,7 @@ func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||
// credentials, marks it connected, closes the ephemeral client, and
|
||||
// renames its token-store directory into the permanent per-user path --
|
||||
// the next real garminFor(ctx, userID) builds a fresh client from scratch
|
||||
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||
// against that now-permanent directory, whose subprocess's lazy
|
||||
// startup-login resumes the just-renamed, still-valid session without
|
||||
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||
@@ -160,14 +187,14 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
sess.Client.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.setupGarmin, claims.Sub)
|
||||
delete(s.setupSession, claims.Sub)
|
||||
s.userAuthStatus[userID] = sess.Status
|
||||
s.userAuthMessage[userID] = sess.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
|
||||
}
|
||||
@@ -175,3 +202,80 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupSession[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupSession[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupSession[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
delete(s.setupSession, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,25 +10,23 @@ import (
|
||||
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||||
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||||
// -- every test in this file needs that starting state, unlike
|
||||
// newTestServer's auto-provisioned default.
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) {
|
||||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
return s, db, m
|
||||
}
|
||||
|
||||
@@ -98,14 +96,14 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
var factoryConfigs []garmin.Config
|
||||
garminFactory := func(cfg garmin.Config) garmin.Client {
|
||||
var factoryConfigs []garmin.ClientConfig
|
||||
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
|
||||
factoryConfigs = append(factoryConfigs, cfg)
|
||||
return m
|
||||
}
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
@@ -145,7 +143,7 @@ func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClie
|
||||
// A later real use must build a genuinely fresh client, configured
|
||||
// against the permanent {userID} token store directory -- never the
|
||||
// stale ephemeral setup/{hash} one the closed client was carrying.
|
||||
if _, err := s.garminFor(newCtx(), u.ID); err != nil {
|
||||
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||
@@ -210,9 +208,9 @@ func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
m := &garmin.MockClient{}
|
||||
tokenStoreRoot := t.TempDir()
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
router := s.Router()
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
m := &mock.Client{}
|
||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
m := &garmin.MockClient{}
|
||||
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
var gotOK bool
|
||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -63,7 +63,7 @@ type AppConfig struct {
|
||||
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||||
// -- it takes the raw map instead of a *store.DB so this package needs no
|
||||
// store dependency and the merge logic tests without a database. A bad
|
||||
// stored value fails fast, same posture as Load() for env vars.
|
||||
// stored value fails fast, same posture as LoadEnv() for env vars.
|
||||
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||||
merged := map[string]string{}
|
||||
for _, k := range appRegistry {
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestValidateAppValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
cfg := Config{
|
||||
Addr: ":8080", OIDCClientSecret: "hunter2",
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
|
||||
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
}
|
||||
entries := map[string]string{}
|
||||
@@ -70,7 +70,7 @@ func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||
}
|
||||
empty := Config{}
|
||||
empty := EnvConfig{}
|
||||
for _, e := range empty.DisplayEnv() {
|
||||
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||
|
||||
@@ -11,18 +11,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds geniusrund's process-level configuration.
|
||||
type Config struct {
|
||||
// Addr is the HTTP listen address, e.g. ":8080".
|
||||
Addr string
|
||||
// EnvConfig holds geniusrund's process-level configuration.
|
||||
type EnvConfig struct {
|
||||
// BackendAddr is the HTTP listen address, e.g. ":8080".
|
||||
BackendAddr string
|
||||
// DBPath is the SQLite database file path.
|
||||
DBPath string
|
||||
|
||||
// GarminPythonPath is the python3 interpreter used to run the embedded
|
||||
// PythonPath is the python3 interpreter used to run the embedded
|
||||
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
|
||||
// Defaults to "python3" resolved via PATH if unset.
|
||||
GarminPythonPath string
|
||||
// GarminTokenStoreRoot is the root directory under which each user's
|
||||
PythonPath string
|
||||
// TokenStoreRoot is the root directory under which each user's
|
||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||
@@ -31,7 +31,7 @@ type Config struct {
|
||||
// per-user isolation automatically -- multi-tenant operation always
|
||||
// relies on this being a real, distinct-per-user path (see
|
||||
// api.Server.garminFor), so it can never be silently left empty.
|
||||
GarminTokenStoreRoot string
|
||||
TokenStoreRoot string
|
||||
|
||||
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||
LogLevel string
|
||||
@@ -62,14 +62,14 @@ type Config struct {
|
||||
SessionSecure bool
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults
|
||||
// LoadEnv reads configuration from environment variables, applying defaults
|
||||
// for anything optional. Returns an error if a required variable is unset.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
func LoadEnv() (EnvConfig, error) {
|
||||
cfg := EnvConfig{
|
||||
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||
@@ -121,7 +121,7 @@ type EnvEntry struct {
|
||||
|
||||
// DisplayEnv returns the environment configuration as a display-safe
|
||||
// list, in stable order, secrets masked.
|
||||
func (c Config) DisplayEnv() []EnvEntry {
|
||||
func (c EnvConfig) DisplayEnv() []EnvEntry {
|
||||
mask := func(set bool) string {
|
||||
if set {
|
||||
return "•••• (set)"
|
||||
@@ -129,10 +129,10 @@ func (c Config) DisplayEnv() []EnvEntry {
|
||||
return "(unset)"
|
||||
}
|
||||
return []EnvEntry{
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
|
||||
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
|
||||
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
|
||||
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
|
||||
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
|
||||
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||||
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||||
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||||
@@ -16,7 +16,7 @@ func setRequiredEnv(t *testing.T) {
|
||||
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv(missing, "")
|
||||
if _, err := Load(); err == nil {
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatalf("expected error when %s is unset", missing)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
if _, err := LoadEnv(); err == nil {
|
||||
t.Fatal("expected error for a session secret under 32 characters")
|
||||
}
|
||||
}
|
||||
@@ -79,12 +79,12 @@ func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminTokenStoreRoot != ".garmin" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.GarminTokenStoreRoot, ".garmin")
|
||||
if cfg.TokenStoreRoot != ".garmin" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
|
||||
if cfg.TokenStoreRoot != "/custom/tokenstores" {
|
||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -132,12 +132,12 @@ func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminPythonPath != "python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3")
|
||||
if cfg.PythonPath != "python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +145,12 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GarminPythonPath != "/opt/venv/bin/python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath)
|
||||
if cfg.PythonPath != "/opt/venv/bin/python3" {
|
||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestLoad_CustomRole(t *testing.T) {
|
||||
setRequiredEnv(t)
|
||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := LoadEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package garmin wraps a direct garminconnect subprocess (see
|
||||
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// geniusrun never deals with the wire protocol directly.
|
||||
package garmin
|
||||
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
//go:embed pyscript/wrapper.py
|
||||
//go:embed wrapper/wrapper.py
|
||||
var wrapperScript string
|
||||
|
||||
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
||||
@@ -57,8 +57,8 @@ type Client interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Config configures how the Garmin wrapper subprocess is spawned.
|
||||
type Config struct {
|
||||
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
|
||||
type ClientConfig struct {
|
||||
// PythonPath is the python3 interpreter to run the embedded wrapper
|
||||
// script with. Empty defaults to "python3" resolved via PATH.
|
||||
PythonPath string
|
||||
@@ -121,7 +121,7 @@ func mapAuthStatus(s string) AuthStatus {
|
||||
// subprocessClient is the real Client implementation, backed by a wrapper
|
||||
// subprocess spoken to over newline-delimited JSON on stdio.
|
||||
type subprocessClient struct {
|
||||
cfg Config
|
||||
cfg ClientConfig
|
||||
|
||||
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
||||
cmd *exec.Cmd
|
||||
@@ -366,7 +366,7 @@ var _ Client = (*subprocessClient)(nil)
|
||||
|
||||
// NewClient builds a Client. The subprocess is not spawned until the first
|
||||
// call that needs it (Authenticate, or any data call once authenticated).
|
||||
func NewClient(cfg Config) Client {
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
return &subprocessClient{cfg: cfg}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/applog"
|
||||
"geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
// wireResponsePayload is what a fake wrapper handler returns for one
|
||||
@@ -166,7 +166,7 @@ func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testi
|
||||
}
|
||||
|
||||
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
||||
|
||||
c.UpdateCredentials("new@example.com", "new")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
|
||||
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||
}
|
||||
|
||||
func toActivityRow(a garmin.Activity) store.Activity {
|
||||
func toActivityRow(a Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
EventTypeKey: a.EventType.TypeKey,
|
||||
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
||||
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for i, l := range laps {
|
||||
@@ -110,9 +109,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||
// risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
out := make([]*garmin.WorkoutStep, lapCount)
|
||||
out := make([]*WorkoutStep, lapCount)
|
||||
|
||||
switch lapCount - len(steps) {
|
||||
case 0, 1:
|
||||
@@ -126,7 +125,7 @@ func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.Workout
|
||||
|
||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||
// range are resolved via the user's Karvonen profile.
|
||||
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
|
||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
|
||||
return out
|
||||
}
|
||||
|
||||
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
||||
func toSampleRows(samples []Sample) []store.Sample {
|
||||
rows := make([]store.Sample, len(samples))
|
||||
for i, s := range samples {
|
||||
rows[i] = store.Sample{
|
||||
@@ -1,21 +1,19 @@
|
||||
// Package mock provides a fake garmin.Client for tests and frontend/dev
|
||||
// MockClient support: a fake Client for tests and frontend/dev
|
||||
// work without a live Garmin account or the wrapper subprocess.
|
||||
package mock
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// Client is a fake garmin.Client returning data supplied by the test/caller.
|
||||
type Client struct {
|
||||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []garmin.Activity
|
||||
Splits map[int64]garmin.ActivitySplits
|
||||
Details map[int64]garmin.ActivityDetails
|
||||
Workouts map[int64]garmin.Workout
|
||||
// MockClient is a fake Client returning data supplied by the test/caller.
|
||||
type MockClient struct {
|
||||
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||
Activities []Activity
|
||||
Splits map[int64]ActivitySplits
|
||||
Details map[int64]ActivityDetails
|
||||
Workouts map[int64]Workout
|
||||
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
|
||||
// return that error for that ID specifically -- independent of the
|
||||
// all-calls-fail Err field below -- so a test can simulate one
|
||||
@@ -37,33 +35,33 @@ type Client struct {
|
||||
LastPassword string
|
||||
}
|
||||
|
||||
var _ garmin.Client = (*Client)(nil)
|
||||
var _ Client = (*MockClient)(nil)
|
||||
|
||||
func (c *Client) nextAuthResult() garmin.AuthResult {
|
||||
func (c *MockClient) nextAuthResult() AuthResult {
|
||||
if c.authResultCursor >= len(c.AuthResults) {
|
||||
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
|
||||
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
|
||||
}
|
||||
r := c.AuthResults[c.authResultCursor]
|
||||
c.authResultCursor++
|
||||
return r
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
||||
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||
c.AuthenticateCalls++
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
|
||||
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.AuthResult{}, c.Err
|
||||
return AuthResult{}, c.Err
|
||||
}
|
||||
return c.nextAuthResult(), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
|
||||
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||
c.GetActivitiesCalls++
|
||||
if c.Delay > 0 {
|
||||
select {
|
||||
@@ -81,36 +79,36 @@ func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, l
|
||||
return c.Activities, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
|
||||
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivitySplits{}, c.Err
|
||||
return ActivitySplits{}, c.Err
|
||||
}
|
||||
return c.Splits[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
|
||||
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.ActivityDetails{}, c.Err
|
||||
return ActivityDetails{}, c.Err
|
||||
}
|
||||
return c.Details[activityID], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
|
||||
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||
if c.Err != nil {
|
||||
return garmin.Workout{}, c.Err
|
||||
return Workout{}, c.Err
|
||||
}
|
||||
if err, ok := c.WorkoutErrByID[workoutID]; ok {
|
||||
return garmin.Workout{}, err
|
||||
return Workout{}, err
|
||||
}
|
||||
return c.Workouts[workoutID], nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCredentials(email, password string) {
|
||||
func (c *MockClient) UpdateCredentials(email, password string) {
|
||||
c.LastEmail = email
|
||||
c.LastPassword = password
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
func (c *MockClient) Close() error {
|
||||
c.ClosedCalled = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package sync orchestrates fetching activities from Garmin (via
|
||||
// Package garmin orchestrates fetching activities from Garmin (via
|
||||
// internal/garmin), persisting them (via internal/store), and classifying
|
||||
// them (via internal/classify). It's the only package that depends on all
|
||||
// three, keeping garmin/store/classify decoupled from each other.
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,15 +14,14 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
||||
// NewService. How far back Backfill reaches is not here -- it's
|
||||
// NewSync. How far back Backfill reaches is not here -- it's
|
||||
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
|
||||
// value takes effect on the next sync without a server restart.
|
||||
type Config struct {
|
||||
type SyncConfig struct {
|
||||
// BackfillWindowDays is the page size for each get_activities call
|
||||
// during backfill.
|
||||
BackfillWindowDays int
|
||||
@@ -39,7 +38,7 @@ type Config struct {
|
||||
MinConfidence float64
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
func (c SyncConfig) withDefaults() SyncConfig {
|
||||
if c.BackfillWindowDays == 0 {
|
||||
c.BackfillWindowDays = 90
|
||||
}
|
||||
@@ -76,37 +75,37 @@ type Progress struct {
|
||||
Total int
|
||||
}
|
||||
|
||||
// Service is the sync orchestrator, scoped to one user -- every store call
|
||||
// Sync is the sync orchestrator, scoped to one user -- every store call
|
||||
// it makes is for userID's data only.
|
||||
type Service struct {
|
||||
garmin garmin.Client
|
||||
type Sync struct {
|
||||
garmin Client
|
||||
db *store.DB
|
||||
userID int64
|
||||
cfg Config
|
||||
cfg SyncConfig
|
||||
now func() time.Time
|
||||
|
||||
progressMu sync.Mutex
|
||||
progress Progress
|
||||
}
|
||||
|
||||
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||||
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
|
||||
// nil (tests can override it for deterministic date windows).
|
||||
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
||||
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||
}
|
||||
|
||||
// Progress returns the current sync progress (phase idle, 0/0 when nothing
|
||||
// is running).
|
||||
func (s *Service) Progress() Progress {
|
||||
func (s *Sync) Progress() Progress {
|
||||
s.progressMu.Lock()
|
||||
defer s.progressMu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
func (s *Service) setProgress(phase string, done, total int) {
|
||||
func (s *Sync) setProgress(phase string, done, total int) {
|
||||
s.progressMu.Lock()
|
||||
s.progress = Progress{Phase: phase, Done: done, Total: total}
|
||||
s.progressMu.Unlock()
|
||||
@@ -126,7 +125,7 @@ func (s *Service) setProgress(phase string, done, total int) {
|
||||
// combined SyncRun; there is no standalone entrypoint for this anymore
|
||||
// (the periodic background sync loop that used to call one is gone -- see
|
||||
// 4d2cbe4 refactor: remove automatic background incremental sync).
|
||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
|
||||
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load profile: %w", err)
|
||||
@@ -194,7 +193,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||
// activity (or a short recent window if none exist yet) through today. Used
|
||||
// by FullSync as one step of its single combined SyncRun -- see
|
||||
// backfillCore's comment for why there's no standalone entrypoint.
|
||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||
@@ -210,7 +209,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||
// new since the latest known activity), then FillPendingDetails --
|
||||
// recorded as a single SyncRun so the reported activity count covers the
|
||||
// whole action instead of only whichever stage happened to finish last.
|
||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -249,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||
// wherever the previous one left off. Workout kinds are left untouched.
|
||||
func (s *Service) ResetAll(ctx context.Context) error {
|
||||
func (s *Sync) ResetAll(ctx context.Context) error {
|
||||
return s.db.ResetAllSyncedData(ctx, s.userID)
|
||||
}
|
||||
|
||||
@@ -265,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
|
||||
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
|
||||
// that found nothing new, just because 2 already-known activities happened
|
||||
// to fall inside the queried window).
|
||||
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||
@@ -297,7 +296,7 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
||||
// (re)classifies every activity touched by the first pass. Each pass makes
|
||||
// its Garmin calls sequentially with Config.InterCallDelay between them to
|
||||
// avoid Garmin/Cloudflare rate limiting.
|
||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
defer s.setProgress(PhaseIdle, 0, 0)
|
||||
|
||||
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
|
||||
@@ -306,7 +305,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
return s.fillPendingWorkouts(ctx, limit)
|
||||
}
|
||||
|
||||
func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -343,7 +342,7 @@ func (s *Service) fillPendingActivityDetails(ctx context.Context, limit int) err
|
||||
// bands are enrichment, not core activity data, and workout_raw_json
|
||||
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
|
||||
// the next sync.
|
||||
func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -364,7 +363,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
}
|
||||
}
|
||||
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||
if errors.Is(err, garmin.ErrNotFound) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// A definitive 404 (the workout was deleted on Garmin's side
|
||||
// after being linked to this activity) will never succeed on
|
||||
// retry -- mark it so ActivitiesMissingWorkout stops
|
||||
@@ -382,7 +381,7 @@ func (s *Service) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_activity_splits: %w", err)
|
||||
@@ -392,14 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
return fmt.Errorf("get_activity_details: %w", err)
|
||||
}
|
||||
|
||||
samples := garmin.ExtractSamples(details)
|
||||
samples := ExtractSamples(details)
|
||||
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
||||
return err
|
||||
}
|
||||
// No workout-target alignment here -- that's fillActivityWorkout's job,
|
||||
// run as its own later pass (see fillPendingWorkouts). targets is an
|
||||
// all-nil placeholder the same length as splits.Laps.
|
||||
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
|
||||
targets := make([]*WorkoutStep, len(splits.Laps))
|
||||
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -416,7 +415,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
||||
// fillActivityDetails, in some earlier pass or run) rather than needing the
|
||||
// original garmin.Lap data again, since alignWorkoutTargets only needs a
|
||||
// count.
|
||||
func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_workout_by_id: %w", err)
|
||||
@@ -441,7 +440,7 @@ func (s *Service) fillActivityWorkout(ctx context.Context, a store.Activity, pro
|
||||
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||
// currently active workout kinds and appends a new kind_assignments row.
|
||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,13 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/classify"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
func openTestDB(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
@@ -58,11 +54,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
total, err := svc.backfillCore(ctx)
|
||||
if err != nil {
|
||||
@@ -85,11 +81,11 @@ func TestBackfillCore_StoresActivities(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "no.target"}},
|
||||
}},
|
||||
}}
|
||||
|
||||
@@ -111,11 +107,11 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
|
||||
// 5-minute prescribed cool-down followed by another 6:46 the athlete
|
||||
// just kept running). The extra lap should have no target, but every
|
||||
// other lap's real target must still come through.
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
||||
}},
|
||||
}}
|
||||
|
||||
@@ -135,9 +131,9 @@ func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T)
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
laps := []Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := Workout{Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO"},
|
||||
}},
|
||||
}}
|
||||
@@ -154,22 +150,22 @@ func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
||||
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
lo, hi := targetPaceRange(WorkoutStep{
|
||||
TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
})
|
||||
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
||||
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
lo, hi = targetPaceRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
lo, hi := targetHRRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
lo, hi := targetHRRange(WorkoutStep{
|
||||
TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
}, store.Profile{})
|
||||
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
||||
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
||||
@@ -180,13 +176,13 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
||||
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
||||
}
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
||||
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
||||
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
lo, hi = targetHRRange(WorkoutStep{TargetType: WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
@@ -197,17 +193,17 @@ func TestBackfillCore_SkipsNonRunningActivities(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "trail_running"},
|
||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
|
||||
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
|
||||
{ActivityID: 3, ActivityType: ActivityType{TypeKey: "paddelball"},
|
||||
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
|
||||
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
||||
{ActivityID: 4, ActivityType: ActivityType{TypeKey: "indoor_cycling"},
|
||||
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -233,27 +229,27 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const garminActivityID = 42
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{
|
||||
Details: map[int64]ActivityDetails{
|
||||
garminActivityID: {
|
||||
ActivityID: garminActivityID,
|
||||
MetricDescriptors: []garmin.MetricDescriptor{
|
||||
MetricDescriptors: []MetricDescriptor{
|
||||
{Key: "directHeartRate", MetricsIndex: 0},
|
||||
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -304,26 +300,26 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||
const garminActivityID = 55
|
||||
const workoutID = 999
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]Workout{
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -356,31 +352,31 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
|
||||
const garminActivityID = 56
|
||||
const workoutID = 1000
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
Details: map[int64]ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]Workout{
|
||||
// One step for two recorded laps -- the second lap is an
|
||||
// unplanned continuation past the workout's end (confirmed via
|
||||
// Garmin Connect against a real activity), not a genuine
|
||||
// mismatch, so the first lap should still get a real target.
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -456,10 +452,10 @@ func TestBackfillCore_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -493,10 +489,10 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -536,12 +532,12 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("first backfillCore: %v", err)
|
||||
@@ -552,7 +548,7 @@ func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||
// watermark (not re-fetch the already-covered recent window) but still
|
||||
// make progress toward the new, deeper horizon.
|
||||
setBackfillHorizon(t, db, userID, 30)
|
||||
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||
svc2 := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10}, now)
|
||||
if _, err := svc2.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("second backfillCore: %v", err)
|
||||
}
|
||||
@@ -574,19 +570,19 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{
|
||||
Details: map[int64]ActivityDetails{
|
||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -633,7 +629,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||
// waitForPhase polls svc.Progress() until it reports phase or timeout
|
||||
// elapses, returning whether it was observed. Polling (rather than a single
|
||||
// fixed-delay sample) keeps this robust against slower/loaded CI machines.
|
||||
func waitForPhase(svc *Service, phase string, timeout time.Duration) bool {
|
||||
func waitForPhase(svc *Sync, phase string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if svc.Progress().Phase == phase {
|
||||
@@ -661,17 +657,17 @@ func TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle(t *testing.T)
|
||||
|
||||
const delay = 200 * time.Millisecond
|
||||
workoutID := int64(101)
|
||||
m := &mock.Client{
|
||||
m := &MockClient{
|
||||
Delay: delay, // gives backfillCore/incrementalSyncCore's GetActivities calls real wall-clock duration
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{1: {ActivityID: 1}},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]garmin.Workout{workoutID: {}},
|
||||
Splits: map[int64]ActivitySplits{1: {ActivityID: 1}},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]Workout{workoutID: {}},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||
svc := NewSync(m, db, userID, SyncConfig{BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
setBackfillHorizon(t, db, userID, 10)
|
||||
|
||||
@@ -711,24 +707,24 @@ func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkout
|
||||
userID := provisionTestUser(t, db)
|
||||
|
||||
const n = 2
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{},
|
||||
Splits: map[int64]garmin.ActivitySplits{},
|
||||
Details: map[int64]garmin.ActivityDetails{},
|
||||
Workouts: map[int64]garmin.Workout{},
|
||||
m := &MockClient{
|
||||
Activities: []Activity{},
|
||||
Splits: map[int64]ActivitySplits{},
|
||||
Details: map[int64]ActivityDetails{},
|
||||
Workouts: map[int64]Workout{},
|
||||
}
|
||||
for i := int64(1); i <= n; i++ {
|
||||
workoutID := i + 100
|
||||
m.Activities = append(m.Activities, garmin.Activity{
|
||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
m.Activities = append(m.Activities, Activity{
|
||||
ActivityID: i, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &workoutID,
|
||||
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
||||
})
|
||||
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
||||
m.Workouts[workoutID] = garmin.Workout{}
|
||||
m.Splits[i] = ActivitySplits{ActivityID: i}
|
||||
m.Details[i] = ActivityDetails{ActivityID: i}
|
||||
m.Workouts[workoutID] = Workout{}
|
||||
}
|
||||
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: 150 * time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore: %v", err)
|
||||
@@ -806,16 +802,16 @@ func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetc
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
m := &MockClient{
|
||||
Workouts: map[int64]Workout{
|
||||
workoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svc := NewSync(m, db, userID, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
|
||||
// return nothing, since details are already fetched) still reaches this
|
||||
@@ -841,22 +837,22 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
|
||||
const failingWorkoutID = 200
|
||||
const okWorkoutID = 201
|
||||
failingPtr, okPtr := int64(failingWorkoutID), int64(okWorkoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &failingPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &okPtr,
|
||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
2: {ActivityID: 2, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
2: {ActivityID: 2, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
okWorkoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}, 2: {ActivityID: 2}},
|
||||
Workouts: map[int64]Workout{
|
||||
okWorkoutID: {Segments: []WorkoutSegment{
|
||||
{Steps: []WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
@@ -864,8 +860,8 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
|
||||
}
|
||||
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
|
||||
// activities/2 workouts here don't cost real wall-clock seconds
|
||||
// (Config{}'s default is 1s per gap).
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||
// (SyncConfig{}'s default is 1s per gap).
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
@@ -909,19 +905,19 @@ func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
|
||||
|
||||
const missingWorkoutID = 300
|
||||
missingPtr := int64(missingWorkoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
|
||||
m := &MockClient{
|
||||
Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
Splits: map[int64]ActivitySplits{
|
||||
1: {ActivityID: 1, Laps: []Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]garmin.Workout{},
|
||||
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
|
||||
Details: map[int64]ActivityDetails{1: {ActivityID: 1}},
|
||||
Workouts: map[int64]Workout{},
|
||||
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", ErrNotFound)},
|
||||
}
|
||||
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||
svc := NewSync(m, db, userID, SyncConfig{InterCallDelay: time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svc.backfillCore(ctx); err != nil {
|
||||
@@ -976,14 +972,14 @@ func TestService_TwoUsersSyncIndependently(t *testing.T) {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
mA := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
mA := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 1, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
mB := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
|
||||
mB := &MockClient{Activities: []Activity{
|
||||
{ActivityID: 2, ActivityType: ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
|
||||
}}
|
||||
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcA := NewSync(mA, db, userA, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
svcB := NewSync(mB, db, userB, SyncConfig{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if _, err := svcA.backfillCore(ctx); err != nil {
|
||||
t.Fatalf("backfillCore(a): %v", err)
|
||||
@@ -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.
|
||||
|
||||
## Backlog
|
||||
- quickfixes
|
||||
- make setupSessionIdleTimeout an application configuration (key session.idle_timeout), check difference with appCfg.SessionDuration
|
||||
- don't put id_token in Claim, store it in the cookie apart from Claim
|
||||
- find alternative to deprecated React.FormEvent
|
||||
- replace all go log calls by our application logger
|
||||
- new workout kinds
|
||||
- add "Recovery", "Quick", and "Sprint" workout kinds
|
||||
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
|
||||
- adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
|
||||
- workout templates
|
||||
- provide workout templates in YAML (see docs/workouts.yaml for the structure)
|
||||
- first time setup page
|
||||
- once connection is validated, add a step to ask user its best race results on 5k, 10k, half-marathon and marathon (at least one shall be provided) and store it in the profile. these values will be updated later during synchronization (as we can detect race kinds with their distance)
|
||||
- add another step to enter latest known MAS (in mm:ss/km) and store it in the profile. this value will be updated later during synchronization (as we can assign an activity with "MAS test" kind)
|
||||
@@ -63,8 +70,9 @@ for that history) and remove it from here once a spec exists.
|
||||
- discovery of warm-up, cool-down phases, so we can read activity per blocks (with detection of extension of a block compared to linked workout)
|
||||
- discovery of additional blocks done but not requested by linked workout (for example, additional small sprints at the end of an easy run)
|
||||
- add icon to indicate if an activity has an associated workout
|
||||
-
|
||||
- list layout with smaller graphs (no scale, no targets, no , detailed view with different graphs for each phase incl. delta with workout)
|
||||
- manage 2 layouts
|
||||
- list layout with smaller graphs (no scale, no targets)
|
||||
- detailed layout (once clicked on details...) showing graphs for each block incl. delta with workout)
|
||||
|
||||
## Someday / maybe
|
||||
|
||||
|
||||
@@ -1256,8 +1256,8 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
|
||||
### Task 7: Classification reads max HR from the profile, not static config
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
|
||||
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
|
||||
- Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
|
||||
- Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
|
||||
@@ -1265,7 +1265,7 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
|
||||
|
||||
- [ ] **Step 1: Update the test first**
|
||||
|
||||
In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
|
||||
In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
|
||||
|
||||
```go
|
||||
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
@@ -1284,7 +1284,7 @@ Expected: passes as-is right now (we haven't removed the field yet) — this ste
|
||||
|
||||
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
|
||||
|
||||
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
|
||||
In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39):
|
||||
|
||||
```go
|
||||
// MinConfidence is the classify.Classify threshold below which even a
|
||||
@@ -1381,7 +1381,7 @@ Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd backend && git add internal/sync/service.go internal/sync/service_test.go
|
||||
cd backend && git add internal/sync/sync.go internal/sync/sync_test.go
|
||||
git commit -m "feat: classification reads max HR from the profile instead of static config"
|
||||
```
|
||||
|
||||
@@ -1390,7 +1390,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
|
||||
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
|
||||
- Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below)
|
||||
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
|
||||
|
||||
**Interfaces:**
|
||||
@@ -1399,7 +1399,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
|
||||
|
||||
- [ ] **Step 1: Rewrite `internal/config/config.go`**
|
||||
|
||||
Replace the full contents of `backend/internal/config/config.go` with:
|
||||
Replace the full contents of `../../../backend/internal/config/envconfig.go` with:
|
||||
|
||||
```go
|
||||
// Package config loads geniusrund's runtime infrastructure configuration
|
||||
@@ -1590,7 +1590,7 @@ Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` r
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd backend && git add internal/config/config.go cmd/geniusrund/main.go
|
||||
cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go
|
||||
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
|
||||
```
|
||||
|
||||
|
||||
@@ -61,15 +61,15 @@ CLAUDE.md [MODIFY] document the new env vars / auth architectur
|
||||
### Task 1: Config — OIDC/session settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go`
|
||||
- Create: `backend/internal/config/config_test.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||
- Create: `../../../backend/internal/config/envconfig_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `backend/internal/config/config_test.go`:
|
||||
Create `../../../backend/internal/config/envconfig_test.go`:
|
||||
|
||||
```go
|
||||
package config
|
||||
@@ -179,7 +179,7 @@ Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the e
|
||||
|
||||
- [ ] **Step 3: Implement the config fields and validation**
|
||||
|
||||
In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
|
||||
In `../../../backend/internal/config/envconfig.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
|
||||
|
||||
```go
|
||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
||||
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
||||
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||
git commit -m "config: add OIDC/session env vars for the login gate"
|
||||
```
|
||||
|
||||
|
||||
@@ -17,25 +17,25 @@
|
||||
- `GARMIN_WRAPPER_PYTHON` is optional, defaulting to `"python3"` (resolved via `PATH`) — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are retired entirely, not renamed.
|
||||
- `garminconnect`'s actual method kwargs must be used exactly: `get_activities_by_date(startdate, enddate)`, `get_activity_splits(activity_id)`, `get_activity_details(activity_id)`, `get_workout_by_id(workout_id)` (confirmed via `inspect.signature` against the installed package — note `startdate`/`enddate`, not `start_date`/`end_date`).
|
||||
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, and `go test ./...` must all pass before the final commit.
|
||||
- `pytest` must pass in `backend/internal/garmin/pyscript/`.
|
||||
- `pytest` must pass in `../../../backend/internal/garmin/wrapper/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Python wrapper (`wrapper.py`) with its own test suite
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/internal/garmin/pyscript/wrapper.py`
|
||||
- Create: `backend/internal/garmin/pyscript/pyproject.toml`
|
||||
- Create: `backend/internal/garmin/pyscript/.gitignore`
|
||||
- Test: `backend/internal/garmin/pyscript/tests/__init__.py`
|
||||
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py`
|
||||
- Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
|
||||
- Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
|
||||
- Create: `../../../backend/internal/garmin/wrapper/.gitignore`
|
||||
- Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
|
||||
- Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `wrapper.dispatch(req: dict) -> dict` — the single entry point Go's subprocess talks to indirectly via stdin/stdout; also `wrapper._auth_state`, `wrapper._client`, `wrapper._mfa_input_queue`, `wrapper._login_result_queue`, `wrapper.Garmin` (re-exported name, patchable in tests), used only within this task's own test suite.
|
||||
|
||||
- [ ] **Step 1: Create the Python project manifest**
|
||||
|
||||
`backend/internal/garmin/pyscript/pyproject.toml`:
|
||||
`../../../backend/internal/garmin/wrapper/pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
@@ -55,7 +55,7 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
```
|
||||
|
||||
`backend/internal/garmin/pyscript/.gitignore`:
|
||||
`../../../backend/internal/garmin/wrapper/.gitignore`:
|
||||
|
||||
```
|
||||
.venv/
|
||||
@@ -68,7 +68,7 @@ __pycache__/
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd backend/internal/garmin/pyscript
|
||||
cd backend/internal/garmin/wrapper
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e ".[dev]"
|
||||
```
|
||||
@@ -76,9 +76,9 @@ Expected: installs `garminconnect` and `pytest` into `.venv` with no errors.
|
||||
|
||||
- [ ] **Step 3: Write the failing test suite**
|
||||
|
||||
`backend/internal/garmin/pyscript/tests/__init__.py`: empty file.
|
||||
`../../../backend/internal/garmin/wrapper/tests/__init__.py`: empty file.
|
||||
|
||||
`backend/internal/garmin/pyscript/tests/test_wrapper.py`:
|
||||
`../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
|
||||
|
||||
- [ ] **Step 5: Write `wrapper.py`**
|
||||
|
||||
`backend/internal/garmin/pyscript/wrapper.py`:
|
||||
`../../../backend/internal/garmin/wrapper/wrapper.py`:
|
||||
|
||||
```python
|
||||
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
||||
@@ -393,7 +393,7 @@ Expected: all tests PASS.
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/garmin/pyscript
|
||||
git add backend/internal/garmin/wrapper
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(garmin): add direct garminconnect wrapper script
|
||||
|
||||
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
|
||||
|
||||
```go
|
||||
// Package garmin wraps a direct garminconnect subprocess (see
|
||||
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||
// geniusrun never deals with the wire protocol directly.
|
||||
package garmin
|
||||
|
||||
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
|
||||
|
||||
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
|
||||
subprocessClient spawns the embedded wrapper/wrapper.py over os/exec and
|
||||
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
|
||||
follow-up commits; this is the transport + lifecycle plumbing only.
|
||||
|
||||
@@ -1370,8 +1370,8 @@ EOF
|
||||
### Task 5: Update `internal/config` (drop server-path env var, default the python path)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go`
|
||||
- Modify: `backend/internal/config/config_test.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from Tasks 1–4.
|
||||
@@ -1379,7 +1379,7 @@ EOF
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `backend/internal/config/config_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
|
||||
In `../../../backend/internal/config/envconfig_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
|
||||
|
||||
```go
|
||||
func setRequiredEnv(t *testing.T) {
|
||||
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `cd backend && go test ./internal/config/... -v`
|
||||
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`GarminPythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
|
||||
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`PythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
|
||||
|
||||
- [ ] **Step 3: Update `config.go`**
|
||||
- [ ] **Step 3: Update `envconfig.go`**
|
||||
|
||||
In `backend/internal/config/config.go`, replace the two Garmin subprocess-path fields:
|
||||
In `../../../backend/internal/config/envconfig.go`, replace the two Garmin subprocess-path fields:
|
||||
|
||||
```go
|
||||
// GarminPythonPath is mcp-garmin's venv python executable.
|
||||
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
||||
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
|
||||
|
||||
@@ -1528,7 +1528,7 @@ to:
|
||||
}, appsync.Config{
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the `GarminBase` doc comment in `server.go`**
|
||||
- [ ] **Step 2: Update the `ClientConfig` doc comment in `server.go`**
|
||||
|
||||
In `backend/internal/api/server.go`, change:
|
||||
|
||||
@@ -1749,4 +1749,4 @@ EOF
|
||||
|
||||
## Follow-up note (not a task in this plan)
|
||||
|
||||
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `backend/internal/garmin/pyscript/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
|
||||
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `../../../backend/internal/garmin/wrapper/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
### Task 1: Add `FrontendURL` to `internal/config`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go`
|
||||
- Modify: `backend/internal/config/config_test.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `backend/internal/config/config_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
|
||||
Add to `../../../backend/internal/config/envconfig_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
|
||||
|
||||
```go
|
||||
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
|
||||
@@ -65,7 +65,7 @@ Expected: compile failure — `Config.FrontendURL` doesn't exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the field and default logic**
|
||||
|
||||
In `backend/internal/config/config.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
|
||||
In `../../../backend/internal/config/envconfig.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
|
||||
|
||||
```go
|
||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
||||
@@ -104,7 +104,7 @@ Expected: all tests PASS, including the two new ones.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
||||
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
|
||||
|
||||
|
||||
@@ -2223,14 +2223,14 @@ EOF
|
||||
## Task 10: `internal/sync.Service` becomes per-user
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/sync/service.go`
|
||||
- Modify: `backend/internal/sync/service_test.go`
|
||||
- Modify: `../../../backend/internal/garmin/sync.go`
|
||||
- Modify: `../../../backend/internal/garmin/sync_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: every scoped store method from Tasks 4-8.
|
||||
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
|
||||
|
||||
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
|
||||
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `../../../backend/internal/garmin/sync.go`**
|
||||
|
||||
Change the `Service` struct and `NewService`:
|
||||
|
||||
@@ -2269,7 +2269,7 @@ Then, in every remaining method, prefix `s.userID` as the new argument to every
|
||||
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)` → `s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)` → `s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)` → `s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)` → `s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)` → `s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
|
||||
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)` → `s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)` → `s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)` → `s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})` → `s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
|
||||
|
||||
- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls**
|
||||
- [ ] **Step 2: Fix `../../../backend/internal/garmin/sync_test.go`'s `NewService` calls**
|
||||
|
||||
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
|
||||
|
||||
@@ -2355,7 +2355,7 @@ Run: `cd backend && gofmt -l internal/sync/`
|
||||
Expected: no output.
|
||||
|
||||
```bash
|
||||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
|
||||
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
sync: scope Service to one user per instance
|
||||
|
||||
@@ -2372,12 +2372,12 @@ EOF
|
||||
## Task 11: `internal/config` additions
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go`
|
||||
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
|
||||
|
||||
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
|
||||
- [ ] **Step 1: Rename `GarminTokenStore` → `TokenStoreRoot` and add `LegacyOwnerOIDCSub`**
|
||||
|
||||
In the `Config` struct, change:
|
||||
|
||||
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
|
||||
Expected: no output.
|
||||
|
||||
```bash
|
||||
git add backend/internal/config/config.go
|
||||
git add backend/internal/config/envconfig.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
config: add per-user Garmin token store root and legacy-owner bootstrap var
|
||||
|
||||
@@ -2442,17 +2442,17 @@ EOF
|
||||
## Task 12: User-resolution middleware + `POST /api/setup`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/internal/api/usercontext.go`
|
||||
- Create: `../../../backend/internal/api/user.go`
|
||||
- Create: `backend/internal/api/setup.go`
|
||||
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
|
||||
- Create: `backend/internal/api/usercontext_test.go`
|
||||
- Create: `../../../backend/internal/api/user_test.go`
|
||||
- Create: `backend/internal/api/setup_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
|
||||
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`**
|
||||
- [ ] **Step 1: Write the failing tests in `../../../backend/internal/api/user_test.go`**
|
||||
|
||||
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
|
||||
|
||||
@@ -2521,7 +2521,7 @@ func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
|
||||
Run: `cd backend && go vet ./internal/api/...`
|
||||
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
|
||||
|
||||
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
|
||||
- [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
|
||||
|
||||
```go
|
||||
package api
|
||||
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
|
||||
Expected: no output.
|
||||
|
||||
```bash
|
||||
git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
|
||||
git add backend/internal/api/user.go backend/internal/api/user_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
api: add user-resolution middleware and POST /api/setup
|
||||
|
||||
@@ -3302,7 +3302,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
||||
- [ ] **Step 4: Build everything and fix remaining call sites**
|
||||
|
||||
Run: `cd backend && go build ./... 2>&1 | head -50`
|
||||
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `auth.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
|
||||
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
|
||||
|
||||
- [ ] **Step 5: `gofmt` and commit**
|
||||
|
||||
@@ -3325,12 +3325,12 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go`
|
||||
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `garmin.go`, `progression.go`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/api/profile.go`
|
||||
- Modify: `backend/internal/api/kinds.go`
|
||||
- Modify: `backend/internal/api/auth.go`
|
||||
- Modify: `../../../backend/internal/api/garmin.go`
|
||||
- Modify: `backend/internal/api/progression.go`
|
||||
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
|
||||
|
||||
@@ -3508,7 +3508,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
|
||||
- [ ] **Step 3: Update `../../../backend/internal/api/garmin.go`**
|
||||
|
||||
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
|
||||
|
||||
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
|
||||
Expected: no output.
|
||||
|
||||
```bash
|
||||
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go
|
||||
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Make connecting a Garmin account a mandatory, persisted gate during onboarding (and on every subsequent login until it succeeds), instead of an optional step left to the Profile page.
|
||||
|
||||
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleAuthLogin`/`handleAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile` → `CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
|
||||
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleGarminAuthLogin`/`handleGarminAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile` → `CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
|
||||
|
||||
**Tech Stack:** Go (`database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
|
||||
|
||||
@@ -246,7 +246,7 @@ git commit -m "feat(store): persist garmin_connected_at, set once on first succe
|
||||
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/api/auth.go` (`recordAuthResult`, both call sites)
|
||||
- Modify: `../../../backend/internal/api/garmin.go` (`recordAuthResult`, both call sites)
|
||||
- Modify: `backend/internal/api/session.go` (`sessionMeResponse`, `handleSessionMe`)
|
||||
- Modify: `backend/internal/api/api_test.go` (new tests)
|
||||
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
|
||||
@@ -342,7 +342,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
|
||||
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
|
||||
|
||||
- [ ] **Step 3: Update `recordAuthResult` in `auth.go`**
|
||||
- [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
|
||||
|
||||
Change the imports:
|
||||
|
||||
@@ -379,8 +379,8 @@ func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.
|
||||
}
|
||||
```
|
||||
|
||||
In `handleAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||
In `handleAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||
In `handleGarminAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||
In `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||
|
||||
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
|
||||
|
||||
@@ -446,7 +446,7 @@ Expected: `gofmt -l .` empty; everything else passes.
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/api/auth.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
|
||||
git add backend/internal/api/garmin.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
|
||||
git commit -m "feat(api): persist and expose garmin_connected on successful auth"
|
||||
```
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
|
||||
- Frontend: `npm run build` (`tsc -b && vite build`) and `npm run lint` (oxlint) must be clean
|
||||
before any commit. No frontend test suite exists -- verify new UI manually in a browser against
|
||||
`cmd/seedsample` data.
|
||||
- Reset all (`ResetAll`/`handleSyncReset`) is explicitly out of scope -- do not touch it beyond
|
||||
- Reset all (`ResetAll`/`handleGarminSyncReset`) is explicitly out of scope -- do not touch it beyond
|
||||
what's incidentally required (none is required).
|
||||
- Follow this repo's existing JSON convention exactly: hand-built response maps use snake_case
|
||||
keys (`in_progress`, `activities_pending_details`), but a Go struct serialized directly (like
|
||||
@@ -40,7 +40,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
|
||||
- Modify: `backend/internal/sync/service.go:99-123` (delete `Backfill`), `:194-209` (delete
|
||||
`IncrementalSync`), `:224-232` (fix `FullSync`'s doc comment)
|
||||
- Modify: `backend/internal/store/syncruns.go:9-11` (delete `SyncKindBackfill`/`SyncKindIncremental`)
|
||||
- Modify: `backend/internal/sync/service_test.go` (rename/rewrite tests listed below)
|
||||
- Modify: `../../../backend/internal/garmin/sync_test.go` (rename/rewrite tests listed below)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
@@ -58,7 +58,7 @@ check, not new information).
|
||||
|
||||
- [ ] **Step 2: Delete `Backfill` and `IncrementalSync`, fix `FullSync`'s doc comment**
|
||||
|
||||
In `backend/internal/sync/service.go`, delete this entire method (lines 99-123):
|
||||
In `../../../backend/internal/garmin/sync.go`, delete this entire method (lines 99-123):
|
||||
|
||||
```go
|
||||
// Backfill pages backward in Config.BackfillWindowDays windows until
|
||||
@@ -187,7 +187,7 @@ const (
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `backend/internal/sync/service_test.go`'s callers**
|
||||
- [ ] **Step 4: Update `../../../backend/internal/garmin/sync_test.go`'s callers**
|
||||
|
||||
Rename and rewrite (drop the redundant SyncRun assertion -- `TestFullSync_RecordsOneCombinedSyncRun`
|
||||
already covers SyncRun recording thoroughly):
|
||||
@@ -261,7 +261,7 @@ Expected: all packages pass, `gofmt -l .` prints nothing.
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/store/syncruns.go
|
||||
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/store/syncruns.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
|
||||
|
||||
@@ -475,7 +475,7 @@ EOF
|
||||
`fillActivityWorkout`)
|
||||
- Modify: `backend/internal/sync/mapping.go:113-125` (`alignWorkoutTargets` signature)
|
||||
- Modify: `backend/internal/garmin/mock/mock.go` (add `WorkoutErrByID`)
|
||||
- Modify: `backend/internal/sync/service_test.go` (update `alignWorkoutTargets` call sites,
|
||||
- Modify: `../../../backend/internal/garmin/sync_test.go` (update `alignWorkoutTargets` call sites,
|
||||
rewrite `TestFillPendingDetails_ReportsLiveProgress`, add new tests)
|
||||
|
||||
**Interfaces:**
|
||||
@@ -490,7 +490,7 @@ EOF
|
||||
`alignWorkoutTargets` only ever uses `len(laps)`, never any lap's actual content -- change its
|
||||
signature to take the count directly, since Task 3's `fillActivityWorkout` (added in Step 4)
|
||||
needs to call it without holding a `[]garmin.Lap` (it only has `[]store.Lap` read back from the
|
||||
DB). In `backend/internal/sync/service_test.go`, update all three call sites:
|
||||
DB). In `../../../backend/internal/garmin/sync_test.go`, update all three call sites:
|
||||
|
||||
```go
|
||||
targets := alignWorkoutTargets(len(laps), workout)
|
||||
@@ -509,7 +509,7 @@ a `[]garmin.Lap`, not an `int` -- `len(laps)` is an `int`, mismatched argument t
|
||||
|
||||
- [ ] **Step 3: Change `alignWorkoutTargets`'s signature**
|
||||
|
||||
In `backend/internal/sync/mapping.go`, replace:
|
||||
In `../../../backend/internal/garmin/mapping.go`, replace:
|
||||
|
||||
```go
|
||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
@@ -553,7 +553,7 @@ one-extra-trailing-lap case stays accurate as-is.)
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && go test ./internal/sync/... -run TestAlignWorkoutTargets -v`
|
||||
Expected: PASS (note: `fillActivityDetails` in `service.go` still calls
|
||||
Expected: PASS (note: `fillActivityDetails` in `sync.go` still calls
|
||||
`alignWorkoutTargets(splits.Laps, workout)` at this point -- that call site is rewritten in Step 6
|
||||
below, so the package won't fully build until then; run this test with `-run
|
||||
TestAlignWorkoutTargets` specifically, or expect a build failure from the not-yet-updated call
|
||||
@@ -561,7 +561,7 @@ site if running the whole package).
|
||||
|
||||
- [ ] **Step 5: Add phase-aware `Progress` and update `FullSync`**
|
||||
|
||||
In `backend/internal/sync/service.go`, replace:
|
||||
In `../../../backend/internal/garmin/sync.go`, replace:
|
||||
|
||||
```go
|
||||
// Progress reports how far a currently-running (or just-finished)
|
||||
@@ -843,7 +843,7 @@ func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Wo
|
||||
|
||||
- [ ] **Step 8: Rewrite `TestFillPendingDetails_ReportsLiveProgress` for phase-awareness, add two new tests**
|
||||
|
||||
Replace `TestFillPendingDetails_ReportsLiveProgress` in `backend/internal/sync/service_test.go`
|
||||
Replace `TestFillPendingDetails_ReportsLiveProgress` in `../../../backend/internal/garmin/sync_test.go`
|
||||
with:
|
||||
|
||||
```go
|
||||
@@ -1045,9 +1045,9 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
|
||||
}
|
||||
```
|
||||
|
||||
`fmt` is already imported in `service_test.go`'s package (`package sync` imports it transitively
|
||||
`fmt` is already imported in `sync_test.go`'s package (`package sync` imports it transitively
|
||||
via other test helpers)? Check: if `go vet`/`go build` complains `fmt` is undefined in
|
||||
`service_test.go`, add `"fmt"` to that file's import block.
|
||||
`sync_test.go`, add `"fmt"` to that file's import block.
|
||||
|
||||
- [ ] **Step 9: Run the full `internal/sync` test suite**
|
||||
|
||||
@@ -1063,7 +1063,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
|
||||
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(sync): split FillPendingDetails into activities/workouts phases
|
||||
|
||||
@@ -1095,7 +1095,7 @@ EOF
|
||||
### Task 4: Reshape `GET /api/sync/status` and update frontend types
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/api/sync.go:69-102` (`handleSyncStatus`)
|
||||
- Modify: `backend/internal/api/sync.go:69-102` (`handleGarminSyncStatus`)
|
||||
- Modify: `backend/internal/api/api_test.go` (add a new test)
|
||||
- Modify: `frontend/src/types/api.ts:197-207` (`DetailFillProgress`/`SyncStatus`, `SyncRun.Kind`)
|
||||
|
||||
@@ -1165,7 +1165,7 @@ handler doesn't produce `workouts_pending` or a nested `progress` object yet (st
|
||||
|
||||
- [ ] **Step 3: Update the handler**
|
||||
|
||||
In `backend/internal/api/sync.go`, replace `handleSyncStatus`:
|
||||
In `backend/internal/api/sync.go`, replace `handleGarminSyncStatus`:
|
||||
|
||||
```go
|
||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1526,7 +1526,7 @@ EOF
|
||||
- [ ] **Step 1: Replace the whole file**
|
||||
|
||||
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic completely unchanged. It loses:
|
||||
`syncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `syncStatus` polling inside
|
||||
`garminSyncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `garminSyncStatus` polling inside
|
||||
`refreshStatus`/the mount `useEffect`, and all inline sync-progress/last-sync JSX. It gains a
|
||||
`showSyncModal` boolean and renders `<SyncModal>` when true.
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ import (
|
||||
)
|
||||
```
|
||||
|
||||
Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
|
||||
Add the `setupSession` field to `Server` and initialize it in `NewServer`:
|
||||
|
||||
```go
|
||||
mu sync.Mutex
|
||||
@@ -395,7 +395,7 @@ Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
|
||||
}
|
||||
```
|
||||
|
||||
Insert this block right after `NewServer` (before `garminFor`):
|
||||
Insert this block right after `NewServer` (before `clientFor`):
|
||||
|
||||
```go
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ git commit -m "feat(store): add DeleteUser with cascading account deletion"
|
||||
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
|
||||
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeUserClient` after `syncFor`), `:167-170` (route)
|
||||
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
|
||||
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
|
||||
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
|
||||
@@ -427,7 +427,7 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
||||
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
|
||||
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
|
||||
|
||||
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
|
||||
- [ ] **Step 3: Add the `os` import and `removeUserClient` to `server.go`**
|
||||
|
||||
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Emit structured JSON logs on stdout for (1) every HTTP API request and (2) every Garmin wrapper subprocess call, correlated by a per-request id — without touching any existing `log.Printf` call site.
|
||||
|
||||
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `requestLoggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
|
||||
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `loggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
|
||||
|
||||
**Tech Stack:** Go stdlib `log/slog` (no new dependency), `github.com/go-chi/chi/v5/middleware` (already-available subpackage of the existing chi dependency).
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/internal/applog/applog.go`
|
||||
- Create: `backend/internal/applog/applog_test.go`
|
||||
- Create: `../../../backend/internal/log/log_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `func NewLogger(level string, w io.Writer) *slog.Logger`, `func WithLogger(ctx context.Context, logger *slog.Logger) context.Context`, `func FromContext(ctx context.Context) *slog.Logger` (never nil — falls back to `slog.Default()`).
|
||||
@@ -91,7 +91,7 @@ func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
|
||||
Run: `cd backend && go test ./internal/applog/... -v`
|
||||
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
|
||||
|
||||
- [ ] **Step 3: Create `backend/internal/applog/applog.go`**
|
||||
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
|
||||
|
||||
```go
|
||||
// Package applog provides geniusrun's structured JSON logging: a
|
||||
@@ -164,19 +164,19 @@ git commit -m "feat(applog): add JSON logger and context helpers"
|
||||
### Task 2: HTTP access log middleware
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/config/config.go` (new `LogLevel` field)
|
||||
- Modify: `backend/internal/config/config_test.go` (new tests)
|
||||
- Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
|
||||
- Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
|
||||
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`)
|
||||
- Modify: `backend/internal/api/server.go` (`requestLoggingMiddleware`, registered in `Router()`)
|
||||
- Modify: `backend/internal/api/server.go` (`loggingMiddleware`, registered in `Router()`)
|
||||
- Modify: `backend/internal/api/api_test.go` (new tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
|
||||
- Produces: `requestLoggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
|
||||
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
|
||||
|
||||
- [ ] **Step 1: Write the failing config tests**
|
||||
|
||||
Append to `backend/internal/config/config_test.go`:
|
||||
Append to `../../../backend/internal/config/envconfig_test.go`:
|
||||
|
||||
```go
|
||||
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||
@@ -276,7 +276,7 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
|
||||
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
|
||||
Expected: FAIL — `LogLevel` field doesn't exist on `Config`; `applog` import unresolved; no log output produced (middleware doesn't exist).
|
||||
|
||||
- [ ] **Step 4: Add `LogLevel` to `config.go`**
|
||||
- [ ] **Step 4: Add `LogLevel` to `envconfig.go`**
|
||||
|
||||
Change:
|
||||
|
||||
@@ -471,7 +471,7 @@ Expected: `gofmt -l .` empty; everything passes.
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/config/config.go backend/internal/config/config_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
|
||||
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
|
||||
git commit -m "feat(api): add structured JSON access log with request-id correlation"
|
||||
```
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ missing" from "not yet fetched."
|
||||
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/garmin/pyscript/wrapper.py` (imports, `dispatch`)
|
||||
- Modify: `backend/internal/garmin/pyscript/tests/test_wrapper.py` (new test)
|
||||
- Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
|
||||
- Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
|
||||
@@ -48,7 +48,7 @@ missing" from "not yet fetched."
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `backend/internal/garmin/pyscript/tests/test_wrapper.py`:
|
||||
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
|
||||
|
||||
```python
|
||||
def test_call_marks_not_found_error_specifically():
|
||||
@@ -77,14 +77,14 @@ def test_call_does_not_mark_other_errors_as_not_found():
|
||||
|
||||
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -k not_found -v`
|
||||
(use whatever Python interpreter this repo's wrapper tests normally run under -- see
|
||||
`backend/internal/garmin/pyscript/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
|
||||
`../../../backend/internal/garmin/wrapper/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
|
||||
Expected: `test_call_marks_not_found_error_specifically` FAILS (`resp` has no `"not_found"` key
|
||||
yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
|
||||
that case).
|
||||
|
||||
- [ ] **Step 3: Update `dispatch()`**
|
||||
|
||||
In `backend/internal/garmin/pyscript/wrapper.py`, replace:
|
||||
In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
|
||||
|
||||
```python
|
||||
from garminconnect import Garmin
|
||||
@@ -144,7 +144,7 @@ Expected: all tests PASS, including both new ones and every existing test unchan
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/garmin/pyscript/wrapper.py backend/internal/garmin/pyscript/tests/test_wrapper.py
|
||||
git add backend/internal/garmin/wrapper/wrapper.py backend/internal/garmin/wrapper/tests/test_wrapper.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(garmin): mark a wrapper 404 with not_found in the error response
|
||||
|
||||
@@ -659,8 +659,8 @@ EOF
|
||||
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/sync/service.go` (`fillPendingWorkouts`)
|
||||
- Modify: `backend/internal/sync/service_test.go` (new test)
|
||||
- Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
|
||||
- Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
|
||||
@@ -668,7 +668,7 @@ EOF
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `backend/internal/sync/service_test.go`, right after
|
||||
Add to `../../../backend/internal/garmin/sync_test.go`, right after
|
||||
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
|
||||
|
||||
```go
|
||||
@@ -743,7 +743,7 @@ regardless of the error's nature).
|
||||
|
||||
- [ ] **Step 3: Update `fillPendingWorkouts`**
|
||||
|
||||
In `backend/internal/sync/service.go`, replace:
|
||||
In `../../../backend/internal/garmin/sync.go`, replace:
|
||||
|
||||
```go
|
||||
for i, a := range pending {
|
||||
@@ -794,7 +794,7 @@ with:
|
||||
}
|
||||
```
|
||||
|
||||
Add `"errors"` to `service.go`'s import block.
|
||||
Add `"errors"` to `sync.go`'s import block.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
@@ -817,7 +817,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
|
||||
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(sync): stop retrying a workout Garmin confirms is gone
|
||||
|
||||
|
||||
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ This replaces the MCP transport with a small custom Python subprocess wrapper ar
|
||||
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
|
||||
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
|
||||
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
|
||||
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
|
||||
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
|
||||
## Rollout notes
|
||||
|
||||
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
|
||||
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
|
||||
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
|
||||
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).
|
||||
|
||||
@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
|
||||
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
|
||||
|
||||
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
|
||||
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
|
||||
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
|
||||
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
|
||||
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
|
||||
|
||||
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
|
||||
## Rollout notes
|
||||
|
||||
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
|
||||
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
|
||||
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
|
||||
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.
|
||||
|
||||
@@ -69,7 +69,7 @@ After the schema edit, regenerate `docs/DATABASE.md` via `go run
|
||||
|
||||
### Backend API
|
||||
|
||||
- **`handleAuthLogin`/`handleAuthMFA`** (`internal/api/auth.go`): right after
|
||||
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
|
||||
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
|
||||
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
|
||||
A failure here is logged and does *not* fail the HTTP response -- the
|
||||
|
||||
@@ -22,7 +22,7 @@ only production caller of the core logic) calls `backfillCore`/`incrementalSyncC
|
||||
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
|
||||
reason they're not already flagged as unused by the compiler.
|
||||
|
||||
Since this plan already restructures `service.go`'s progress model, remove these two dead
|
||||
Since this plan already restructures `sync.go`'s progress model, remove these two dead
|
||||
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
|
||||
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
|
||||
directly (same package, so unexported functions are still directly testable) -- before any of
|
||||
@@ -120,7 +120,7 @@ able to report it; the frontend type was just never updated to match).
|
||||
## Frontend design
|
||||
|
||||
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
|
||||
its `syncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
|
||||
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
|
||||
JSX.
|
||||
|
||||
A new `SyncModal.tsx`:
|
||||
|
||||
@@ -31,7 +31,7 @@ commit); it just no longer gates anything on its own.
|
||||
|
||||
### The core problem: Garmin auth needs an account today
|
||||
|
||||
`garminFor` builds a per-user `garmin.Client` keyed by a DB user id and
|
||||
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
|
||||
reads Garmin credentials from that user's `profile` row. To authenticate
|
||||
with Garmin *before* an account exists, onboarding needs a **temporary,
|
||||
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
|
||||
@@ -45,7 +45,7 @@ finish onboarding.
|
||||
### Backend: ephemeral pre-account Garmin sessions
|
||||
|
||||
New type and `Server` field (`server.go`, alongside the existing
|
||||
`userGarmin`/`userSync`/etc. per-user maps):
|
||||
`userClient`/`userSync`/etc. per-user maps):
|
||||
|
||||
```go
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
@@ -81,7 +81,7 @@ Helper methods on `Server` (`server.go`):
|
||||
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
|
||||
fresh ephemeral client whose `TokenStorePath` is namespaced under
|
||||
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
|
||||
namespacing `garminFor` uses, so two people onboarding concurrently never
|
||||
namespacing `clientFor` uses, so two people onboarding concurrently never
|
||||
collide, and so the ephemeral session's persisted Garmin tokens have
|
||||
*some* home even though no user id exists yet).
|
||||
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
|
||||
@@ -140,7 +140,7 @@ r.Route("/setup", func(r chi.Router) {
|
||||
resumes the already-established Garmin session instead of requiring a
|
||||
fresh login. Logged, not fatal, on error.
|
||||
|
||||
`internal/api/auth.go`'s existing `handleAuthLogin`/`handleAuthMFA`/`handleAuthStatus`
|
||||
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
|
||||
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
|
||||
already-provisioned user) are untouched.
|
||||
|
||||
@@ -204,7 +204,7 @@ simpler than threading a separate `"mfa"` step through, and matches how
|
||||
`/api/setup/complete` after a successful session creates the user with
|
||||
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
|
||||
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
|
||||
via `garminFor` returning that exact pointer, and `ClosedCalled` still
|
||||
via `clientFor` returning that exact pointer, and `ClosedCalled` still
|
||||
false -- proving no redundant re-authentication happened);
|
||||
`/api/setup/complete` with no prior login attempt → 409;
|
||||
already-provisioned subject hitting either endpoint → 409; two different
|
||||
|
||||
@@ -74,7 +74,7 @@ the `requireProvisionedUser` group (`server.go`). `userID` comes from
|
||||
`client.UpdateCredentials` outside `s.mu`).
|
||||
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
|
||||
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
|
||||
the same path `garminFor` computes when building a client. Log on
|
||||
the same path `clientFor` computes when building a client. Log on
|
||||
error; this is cleanup of an already-orphaned directory, not something
|
||||
that should fail the request that already deleted the DB row.
|
||||
4. Respond `204 No Content`.
|
||||
|
||||
@@ -75,7 +75,7 @@ which falls back to this default.
|
||||
|
||||
### HTTP access log (`internal/api`)
|
||||
|
||||
A new `requestLoggingMiddleware`, registered as the **first** `r.Use(...)`
|
||||
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
|
||||
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
|
||||
including unauthenticated ones (login redirect, health check) and OPTIONS
|
||||
preflights:
|
||||
|
||||
@@ -128,7 +128,7 @@ Every current inline-error site, and what changes:
|
||||
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
|
||||
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
|
||||
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
|
||||
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `submitMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
|
||||
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
|
||||
|
||||
**Dead CSS removal:** once no `.tsx` file references them, delete the
|
||||
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from
|
||||
|
||||
12
docs/workouts.yaml
Normal file
12
docs/workouts.yaml
Normal 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
|
||||
@@ -80,20 +80,20 @@ export const api = {
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
}),
|
||||
|
||||
// Auth
|
||||
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
||||
submitMFA: (code: string) =>
|
||||
request<AuthResponse>("/api/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
authStatus: () => request<AuthResponse>("/api/auth/status"),
|
||||
// Garmin Auth
|
||||
garminLogin: () => request<AuthResponse>("/api/garmin/auth/login", { method: "POST" }),
|
||||
garminMFA: (code: string) =>
|
||||
request<AuthResponse>("/api/garmin/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
garminStatus: () => request<AuthResponse>("/api/garmin/auth/status"),
|
||||
|
||||
// Sync
|
||||
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
|
||||
// Garmin Sync
|
||||
garminSyncRun: () => request<{ status: string }>("/api/garmin/sync/run", { method: "POST" }),
|
||||
// Deletes every synced activity (and its laps/samples/kind assignments)
|
||||
// and rewinds the backfill watermark -- destructive, gated by a
|
||||
// confirmation in the UI.
|
||||
resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }),
|
||||
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
||||
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
||||
garminSyncReset: () => request<{ status: string }>("/api/garmin/sync/reset", { method: "POST" }),
|
||||
garminSyncRuns: () => request<SyncRun[]>("/api/garmin/sync/runs"),
|
||||
garminSyncStatus: () => request<SyncStatus>("/api/garmin/sync/status"),
|
||||
|
||||
// Training types -- fixed taxonomy, no create/delete
|
||||
listWorkoutKinds: (includeInactive = false) =>
|
||||
|
||||
@@ -18,9 +18,9 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
||||
// it's open, so this no longer needs the "poll faster while syncing"
|
||||
// dynamic interval it used to have.
|
||||
useEffect(() => {
|
||||
api.authStatus().then(setAuth).catch((e) => showError(String(e)));
|
||||
api.garminStatus().then(setAuth).catch((e) => showError(String(e)));
|
||||
const interval = setInterval(() => {
|
||||
api.authStatus().then(setAuth).catch(() => {});
|
||||
api.garminStatus().then(setAuth).catch(() => {});
|
||||
}, 6000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
@@ -29,7 +29,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
||||
setBusy(true);
|
||||
try {
|
||||
await onBeforeConnect?.();
|
||||
const result = await api.login();
|
||||
const result = await api.garminLogin();
|
||||
setAuth(result);
|
||||
setDisconnected(false);
|
||||
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
||||
@@ -48,7 +48,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
||||
if (!code.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api.submitMFA(code.trim());
|
||||
const result = await api.garminMFA(code.trim());
|
||||
setAuth(result);
|
||||
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
||||
} catch (e) {
|
||||
@@ -61,7 +61,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
||||
async function sync() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.syncRun();
|
||||
await api.garminSyncRun();
|
||||
setShowSyncModal(true);
|
||||
} catch (e) {
|
||||
showError(String(e));
|
||||
@@ -76,14 +76,14 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.resetSync();
|
||||
await api.garminSyncReset();
|
||||
// Reset runs as a background sync job; wait for it to actually finish
|
||||
// before reloading, otherwise other pages (e.g. Activities) would
|
||||
// still show the just-deleted activities from their own stale state.
|
||||
let status = await api.syncStatus();
|
||||
let status = await api.garminSyncStatus();
|
||||
while (status.in_progress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
status = await api.syncStatus();
|
||||
status = await api.garminSyncStatus();
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
|
||||
@@ -61,7 +61,7 @@ export function SyncModal({ onClose }: { onClose: () => void }) {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const tick = () => {
|
||||
api
|
||||
.syncStatus()
|
||||
.garminSyncStatus()
|
||||
.then((s) => {
|
||||
setStatus(s);
|
||||
if (!s.in_progress) {
|
||||
|
||||
Reference in New Issue
Block a user