diff --git a/backend/geniusrun.db-shm b/backend/geniusrun.db-shm deleted file mode 100644 index e5fb1f4..0000000 Binary files a/backend/geniusrun.db-shm and /dev/null differ diff --git a/backend/geniusrun.db-wal b/backend/geniusrun.db-wal deleted file mode 100644 index 067e2a1..0000000 Binary files a/backend/geniusrun.db-wal and /dev/null differ diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 71c71fb..f5bced1 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -715,7 +715,7 @@ func itoa(v int64) string { } func TestProfile_GetDefaultsThenUpdate(t *testing.T) { - s, _, _ := newTestServer(t) + s, db, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) @@ -733,10 +733,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) { got.GarminEmail = "runner@example.com" got.GarminPassword = "hunter2" got.RollingWindowDays = 120 - rec = doJSON(t, router, http.MethodPut, "/api/profile", got) + // Name rides along in the profile payload but lives on the users row. + rec = doJSON(t, router, http.MethodPut, "/api/profile", struct { + store.Profile + Name string + }{got, "Renamed Runner"}) if rec.Code != http.StatusOK { t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) } + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found || u.Name != "Renamed Runner" { + t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err) + } rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) var updated store.Profile @@ -1146,7 +1154,7 @@ func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) } - if entry["msg"] != "http request" { + if entry["msg"] != "HTTP request" { t.Errorf("msg = %v, want \"http request\"", entry["msg"]) } if entry["method"] != "GET" || entry["path"] != "/api/health" { diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go index 11b0e47..da59c0d 100644 --- a/backend/internal/api/profile.go +++ b/backend/internal/api/profile.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "geniusrun/backend/internal/store" ) @@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error { return nil } +// profilePayload is the profile API's request/response shape: the profiles +// row plus Name, which lives on the users row (the account's single +// human-facing name) but is edited from the same Profile screen. +type profilePayload struct { + store.Profile + Name string +} + func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) p, err := s.DB.GetProfile(r.Context(), userID) @@ -42,22 +51,31 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, p) + u, _ := userFromContext(r.Context()) + writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name}) } func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) - var p store.Profile + var p profilePayload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - if err := validateProfile(p); err != nil { + if strings.TrimSpace(p.Name) == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if err := validateProfile(p.Profile); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil { + if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -73,7 +91,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, updated) + writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)}) } // handleDeleteProfile permanently deletes the signed-in user's entire diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index e6f2e21..5f894c0 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -184,7 +184,7 @@ func loggingMiddleware(next http.Handler) http.Handler { if ww.Status() >= 500 { level = slog.LevelWarn } - logger.LogAttrs(r.Context(), level, "http request", + logger.LogAttrs(r.Context(), level, "HTTP request", slog.String("method", r.Method), slog.String("path", r.URL.Path), slog.Int("status", ww.Status()), diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 3225fe5..11d1ce7 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -114,7 +114,7 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} if u, found := userFromContext(r.Context()); found { resp.HasProfile = true - resp.DisplayName = u.DisplayName + resp.DisplayName = u.Name profile, err := s.DB.GetProfile(r.Context(), u.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/backend/internal/api/user.go b/backend/internal/api/user.go index 7595bf4..012e61e 100644 --- a/backend/internal/api/user.go +++ b/backend/internal/api/user.go @@ -14,8 +14,8 @@ const resolvedUserContextKey userContextKey = iota // resolvedUser is the geniusrun account (if any) bound to the current // session's OIDC subject. type resolvedUser struct { - ID int64 - DisplayName string + ID int64 + Name string } // resolveUser runs after auth.RequireSession on every request and looks up @@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler { } ctx := r.Context() if found { - ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName}) + ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name}) } next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/backend/internal/store/isolation_test.go b/backend/internal/store/isolation_test.go index bd8f1fe..706c429 100644 --- a/backend/internal/store/isolation_test.go +++ b/backend/internal/store/isolation_test.go @@ -188,12 +188,12 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) { if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found { t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err) } - profileB, err := db.GetProfile(ctx, userB) - if err != nil { + if _, err := db.GetProfile(ctx, userB); err != nil { t.Fatalf("GetProfile(b) after deleting a: %v", err) } - if profileB.Name != "B" { - t.Errorf("userB's profile changed after deleting userA: %+v", profileB) + userBRow, found, err := db.GetUserBySub(ctx, "sub-b") + if err != nil || !found || userBRow.Name != "B" { + t.Errorf("userB's account changed after deleting userA: %+v (found=%v err=%v)", userBRow, found, err) } kindsB, err := db.ListWorkoutKinds(ctx, userB, false) if err != nil || len(kindsB) != 8 { diff --git a/backend/internal/store/laps.go b/backend/internal/store/laps.go index 08ede5e..dfacbeb 100644 --- a/backend/internal/store/laps.go +++ b/backend/internal/store/laps.go @@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps [] } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM activity_laps WHERE activity_id = ?`, activityID); err != nil { return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err) } for _, l := range laps { _, err := tx.ExecContext(ctx, ` - INSERT INTO laps ( + INSERT INTO activity_laps ( activity_id, lap_index, avg_speed_mps, intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json @@ -84,7 +84,7 @@ func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([] SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps, laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min, laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json - FROM laps + FROM activity_laps AS laps JOIN activities ON activities.id = laps.activity_id WHERE laps.activity_id = ? AND activities.user_id = ? ORDER BY laps.lap_index`, activityID, userID) diff --git a/backend/internal/store/profile.go b/backend/internal/store/profile.go index a4de766..a0ed676 100644 --- a/backend/internal/store/profile.go +++ b/backend/internal/store/profile.go @@ -8,9 +8,6 @@ import ( // Profile is the single active user's Garmin credentials plus every // tunable analysis-engine parameter. Always exactly one row (id=1). type Profile struct { - // Name labels this profile so a future multi-profile setup can show - // which one is active. Only one profile row exists today (id=1). - Name string GarminEmail string GarminPassword string // GarminConnectedAt is nil until this user's first successful Garmin @@ -76,7 +73,7 @@ type Profile struct { } const profileColumns = ` - name, garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate, + garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate, hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, hr_zone5_min_pct, hr_zone5_max_pct, @@ -90,8 +87,8 @@ const profileColumns = ` // GetProfile returns the profile row for userID. func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { var p Profile - err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( - &p.Name, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, + err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan( + &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct, @@ -112,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { // replaces every column. func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { _, err := db.ExecContext(ctx, ` - UPDATE profile SET - name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, + UPDATE profiles SET + garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?, hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, hr_zone5_min_pct=?, hr_zone5_max_pct=?, @@ -123,7 +120,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?, updated_at=datetime('now') WHERE user_id = ?`, - p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, + p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone5MinPct, p.HRZone5MaxPct, @@ -143,7 +140,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error // authenticates with Garmin. A no-op if already set, so it always reflects // the first connection, not the most recent one. func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error { - _, err := db.ExecContext(ctx, `UPDATE profile SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID) + _, err := db.ExecContext(ctx, `UPDATE profiles SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID) if err != nil { return fmt.Errorf("mark garmin connected for user %d: %w", userID, err) } diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go index 137bc4d..8b1ca21 100644 --- a/backend/internal/store/profile_test.go +++ b/backend/internal/store/profile_test.go @@ -32,9 +32,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 { t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p) } - if p.Name != "Default" { - t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default") - } if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" { t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p) } @@ -52,7 +49,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { } maxHR, restingHR := 190.0, 50.0 - p.Name = "Kriss" p.PaceColor = "#111111" p.EffortColor = "#222222" p.MainLineTintPct = 45 @@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { t.Errorf("got = %+v, want updated email/window", got) } - if got.Name != "Kriss" { - t.Errorf("Name = %q, want %q after update", got.Name, "Kriss") - } if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 { t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) } diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql index fc4b42c..1646696 100644 --- a/backend/internal/store/schema.sql +++ b/backend/internal/store/schema.sql @@ -8,26 +8,26 @@ -- here rather than appending an ALTER TABLE migration. -- One geniusrun account per OIDC subject. Every other table below is scoped --- to a user_id, directly (profile/workout_kinds/activities/sync_state/ --- sync_runs) or transitively through a JOIN to the owning row (laps/ +-- to a user_id, directly (profiles/workout_kinds/activities/sync_state/ +-- sync_runs) or transitively through a JOIN to the owning row (activity_laps/ -- activity_samples/kind_assignments/workout_type_paces, which have no -- user_id column of their own since they're never queried except through a --- specific activity or workout kind). +-- specific activity or workout kind). name is the account's single +-- human-facing name: set at onboarding, editable from the Profile page. CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - oidc_sub TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + oidc_sub TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- One row per user: Garmin credentials plus every tunable analysis-engine -- parameter (HR zones, phase-detection minutes, pace-artifact filtering, -- chart colors). store.ProvisionUser creates this (and everything else -- below) in one transaction when a new account signs up. -CREATE TABLE profile ( +CREATE TABLE profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Default', garmin_email TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '', -- Set once, the first time this user successfully authenticates with @@ -168,7 +168,7 @@ CREATE INDEX idx_activities_start_time ON activities(start_time_utc); -- One row per lap/split (from get_activity_splits), plus HR drift/recovery -- derived from activity_samples. No user_id column -- always accessed -- through a specific owning activity. -CREATE TABLE laps ( +CREATE TABLE activity_laps ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, lap_index INTEGER NOT NULL, diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 23a044e..e12c9e6 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -236,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) { ctx := context.Background() // UNIQUE(user_id, name) allows the same name across two different users. - if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil { + if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil { t.Fatalf("insert users: %v", err) } if _, err := db.ExecContext(ctx, ` @@ -247,10 +247,10 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) { } // profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate. - if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil { + if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil { t.Fatalf("insert profile for sub-a: %v", err) } - if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil { + if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil { t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)") } } diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go index 556d8f7..250eae8 100644 --- a/backend/internal/store/users.go +++ b/backend/internal/store/users.go @@ -11,18 +11,18 @@ import ( // state) is scoped to exactly one User -- see // docs/superpowers/specs/2026-07-25-per-user-profile-design.md. type User struct { - ID int64 - OIDCSub string - DisplayName string - CreatedAt string + ID int64 + OIDCSub string + Name string + CreatedAt string } // GetUserBySub looks up a user by their OIDC subject -- the only lookup key // the session-resolution middleware ever uses. func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) { var u User - err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). - Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) + err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). + Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt) if err == sql.ErrNoRows { return User{}, false, nil } @@ -63,7 +63,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i } defer tx.Rollback() - res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES (?, ?)`, oidcSub, displayName) if err != nil { return 0, fmt.Errorf("create user %q: %w", oidcSub, err) } @@ -72,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i return 0, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES (?)`, userID); err != nil { return 0, fmt.Errorf("create profile for user %d: %w", userID, err) } @@ -113,3 +113,12 @@ func (db *DB) DeleteUser(ctx context.Context, userID int64) error { } return nil } + +// UpdateUserName renames userID's account -- the single human-facing name +// (shown in the header and session info), editable from the Profile page. +func (db *DB) UpdateUserName(ctx context.Context, userID int64, name string) error { + if _, err := db.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, name, userID); err != nil { + return fmt.Errorf("update name for user %d: %w", userID, err) + } + return nil +} diff --git a/backend/internal/store/users_test.go b/backend/internal/store/users_test.go index 02020d6..71086c0 100644 --- a/backend/internal/store/users_test.go +++ b/backend/internal/store/users_test.go @@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) { if err != nil || !found { t.Fatalf("GetUserBySub: found=%v err=%v", found, err) } - if u.ID != userID || u.DisplayName != "Lucie" { - t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) + if u.ID != userID || u.Name != "Lucie" { + t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID) } profile, err := db.GetProfile(ctx, userID) if err != nil { t.Fatalf("GetProfile: %v", err) } - if profile.Name != "Lucie" { - t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie") - } if profile.RollingWindowDays != 90 { t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) } @@ -142,11 +139,11 @@ func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) { query string arg int64 }{ - {`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID}, + {`SELECT COUNT(*) FROM profiles WHERE user_id = ?`, userID}, {`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID}, {`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID}, {`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID}, - {`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID}, + {`SELECT COUNT(*) FROM activity_laps WHERE activity_id = ?`, activityID}, {`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID}, {`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID}, {`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID}, diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 050d82c..e6c2a45 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -5,11 +5,11 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit ## Tables - [`users`](#users) -- [`profile`](#profile) +- [`profiles`](#profiles) - [`workout_kinds`](#workout_kinds) - [`workout_type_paces`](#workout_type_paces) - [`activities`](#activities) -- [`laps`](#laps) +- [`activity_laps`](#activity_laps) - [`activity_samples`](#activity_samples) - [`kind_assignments`](#kind_assignments) - [`sync_state`](#sync_state) @@ -20,20 +20,19 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit ```sql CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - oidc_sub TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + oidc_sub TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); ``` -## `profile` +## `profiles` ```sql -CREATE TABLE profile ( +CREATE TABLE profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Default', garmin_email TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '', -- Set once, the first time this user successfully authenticates with @@ -171,10 +170,10 @@ Indexes: CREATE INDEX idx_activities_start_time ON activities(start_time_utc); ``` -## `laps` +## `activity_laps` ```sql -CREATE TABLE laps ( +CREATE TABLE activity_laps ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, lap_index INTEGER NOT NULL, diff --git a/docs/IDEAS.md b/docs/IDEAS.md index c4e72af..34b9198 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -11,7 +11,7 @@ for that history) and remove it from here once a spec exists. - 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 + - replace all standard 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 diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index 6fb44fa..6ad8232 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -84,6 +84,7 @@ export function LoginGate() { if (!session!.has_profile) { return ( setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} /> ); diff --git a/frontend/src/OnboardingWizard.tsx b/frontend/src/OnboardingWizard.tsx index 9ef571d..3d4fab7 100644 --- a/frontend/src/OnboardingWizard.tsx +++ b/frontend/src/OnboardingWizard.tsx @@ -15,9 +15,17 @@ type Step = "name" | "garmin"; // database -- there's no half-created account to clean up or re-prompt // for later, so there's deliberately no "Log out" escape hatch anywhere in // this flow: closing the tab *is* the escape hatch. -export function OnboardingWizard({ onCreated }: { onCreated: (displayName: string) => void }) { +// defaultName pre-fills the display-name field with the OIDC claim's name -- +// most people keep it, and a single ✓ click through is nicer than retyping. +export function OnboardingWizard({ + onCreated, + defaultName = "", +}: { + onCreated: (displayName: string) => void; + defaultName?: string; +}) { const [step, setStep] = useState("name"); - const [displayName, setDisplayName] = useState(""); + const [displayName, setDisplayName] = useState(defaultName); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [code, setCode] = useState("");