From 042579a3965ebd3465fd0be28d528c4061381627 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 4 Aug 2026 16:11:23 +0200 Subject: [PATCH] refactor(store): single account name on users; rename profile/laps tables users.display_name becomes users.name and is now the only human-facing name -- profiles.name is dropped (it duplicated the account name; the Profile page's Name field now edits users.name through PUT /api/profile, which carries Name alongside the profiles columns). The profile table becomes profiles and laps becomes activity_laps, homogeneous with activity_samples. Onboarding pre-fills the display name from the OIDC claim's name. Co-Authored-By: Claude Fable 5 --- backend/geniusrun.db-shm | Bin 32768 -> 0 bytes backend/geniusrun.db-wal | Bin 111272 -> 0 bytes backend/internal/api/api_test.go | 14 +++++++++--- backend/internal/api/profile.go | 28 +++++++++++++++++++---- backend/internal/api/server.go | 2 +- backend/internal/api/session.go | 2 +- backend/internal/api/user.go | 6 ++--- backend/internal/store/isolation_test.go | 8 +++---- backend/internal/store/laps.go | 6 ++--- backend/internal/store/profile.go | 17 ++++++-------- backend/internal/store/profile_test.go | 7 ------ backend/internal/store/schema.sql | 20 ++++++++-------- backend/internal/store/store_test.go | 6 ++--- backend/internal/store/users.go | 25 +++++++++++++------- backend/internal/store/users_test.go | 11 ++++----- docs/DATABASE.md | 21 ++++++++--------- docs/IDEAS.md | 2 +- frontend/src/LoginGate.tsx | 1 + frontend/src/OnboardingWizard.tsx | 12 ++++++++-- 19 files changed, 109 insertions(+), 79 deletions(-) delete mode 100644 backend/geniusrun.db-shm delete mode 100644 backend/geniusrun.db-wal diff --git a/backend/geniusrun.db-shm b/backend/geniusrun.db-shm deleted file mode 100644 index e5fb1f48adafc3e324eeb4c9a66c3c0a216cbec1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*Ju*XK7zNHK_PmIBx>jAUZd5m`Th;CAu)0&-t&XaD)p2#dI;kF1535JjY4x~zQqAuW z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!Cu13uN~|x(H--Li#0;#SiHski8Y@mq4~|q>Dh7hNNEt zS$UE!0@>J-ehFmXOu7hUT~7KXVA-$h|CwDyA<(8k7Da_Xn*!O=6#{JvWJOvCv?-AN zbRp2DKrROefi?wlGeZcpDUfS1LZD57+({AwZ3^T^K$`-&3MmBI6v#bP aA<(8kE&&UHHU)A6TL`o%kn8W#Mc@PCawUWS diff --git a/backend/geniusrun.db-wal b/backend/geniusrun.db-wal deleted file mode 100644 index 067e2a1ac324ad7d78d018ac58a15bdd5ffd4128..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111272 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 P1egF5U;<3wHYf1E(872? 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("");