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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:11:23 +02:00
parent e2b2bf9611
commit 042579a396
19 changed files with 109 additions and 79 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -715,7 +715,7 @@ func itoa(v int64) string {
} }
func TestProfile_GetDefaultsThenUpdate(t *testing.T) { func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _, _ := newTestServer(t) s, db, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) 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.GarminEmail = "runner@example.com"
got.GarminPassword = "hunter2" got.GarminPassword = "hunter2"
got.RollingWindowDays = 120 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 { if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) 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) rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile var updated store.Profile
@@ -1146,7 +1154,7 @@ func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) 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"]) t.Errorf("msg = %v, want \"http request\"", entry["msg"])
} }
if entry["method"] != "GET" || entry["path"] != "/api/health" { if entry["method"] != "GET" || entry["path"] != "/api/health" {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
return nil 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) { func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
p, err := s.DB.GetProfile(r.Context(), userID) 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()) writeError(w, http.StatusInternalServerError, err.Error())
return 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) { func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
var p store.Profile var p profilePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, http.StatusBadRequest, "invalid request body")
return 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()) writeError(w, http.StatusBadRequest, err.Error())
return 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
@@ -73,7 +91,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
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 // handleDeleteProfile permanently deletes the signed-in user's entire

View File

@@ -184,7 +184,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
if ww.Status() >= 500 { if ww.Status() >= 500 {
level = slog.LevelWarn level = slog.LevelWarn
} }
logger.LogAttrs(r.Context(), level, "http request", logger.LogAttrs(r.Context(), level, "HTTP request",
slog.String("method", r.Method), slog.String("method", r.Method),
slog.String("path", r.URL.Path), slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()), slog.Int("status", ww.Status()),

View File

@@ -114,7 +114,7 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found { if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true resp.HasProfile = true
resp.DisplayName = u.DisplayName resp.DisplayName = u.Name
profile, err := s.DB.GetProfile(r.Context(), u.ID) profile, err := s.DB.GetProfile(r.Context(), u.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -14,8 +14,8 @@ const resolvedUserContextKey userContextKey = iota
// resolvedUser is the geniusrun account (if any) bound to the current // resolvedUser is the geniusrun account (if any) bound to the current
// session's OIDC subject. // session's OIDC subject.
type resolvedUser struct { type resolvedUser struct {
ID int64 ID int64
DisplayName string Name string
} }
// resolveUser runs after auth.RequireSession on every request and looks up // 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() ctx := r.Context()
if found { 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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@@ -188,12 +188,12 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found { 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) t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
} }
profileB, err := db.GetProfile(ctx, userB) if _, err := db.GetProfile(ctx, userB); err != nil {
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err) t.Fatalf("GetProfile(b) after deleting a: %v", err)
} }
if profileB.Name != "B" { userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
t.Errorf("userB's profile changed after deleting userA: %+v", profileB) 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) kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 { if err != nil || len(kindsB) != 8 {

View File

@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
} }
defer tx.Rollback() 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) return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
} }
for _, l := range laps { for _, l := range laps {
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO laps ( INSERT INTO activity_laps (
activity_id, lap_index, avg_speed_mps, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, 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 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, 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.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 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 JOIN activities ON activities.id = laps.activity_id
WHERE laps.activity_id = ? AND activities.user_id = ? WHERE laps.activity_id = ? AND activities.user_id = ?
ORDER BY laps.lap_index`, activityID, userID) ORDER BY laps.lap_index`, activityID, userID)

View File

@@ -8,9 +8,6 @@ import (
// Profile is the single active user's Garmin credentials plus every // Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1). // tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct { 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 GarminEmail string
GarminPassword string GarminPassword string
// GarminConnectedAt is nil until this user's first successful Garmin // GarminConnectedAt is nil until this user's first successful Garmin
@@ -76,7 +73,7 @@ type Profile struct {
} }
const profileColumns = ` 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_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_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct, hr_zone5_min_pct, hr_zone5_max_pct,
@@ -90,8 +87,8 @@ const profileColumns = `
// GetProfile returns the profile row for userID. // GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct,
@@ -112,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
// replaces every column. // replaces every column.
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
UPDATE profile SET UPDATE profiles SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, 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_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_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_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=?, main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now') updated_at=datetime('now')
WHERE user_id = ?`, 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.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct, 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 // authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one. // the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error { 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 { if err != nil {
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err) return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
} }

View File

@@ -32,9 +32,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 { if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p) 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" { if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p) 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 maxHR, restingHR := 190.0, 50.0
p.Name = "Kriss"
p.PaceColor = "#111111" p.PaceColor = "#111111"
p.EffortColor = "#222222" p.EffortColor = "#222222"
p.MainLineTintPct = 45 p.MainLineTintPct = 45
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got) 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 { if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
} }

View File

@@ -8,26 +8,26 @@
-- here rather than appending an ALTER TABLE migration. -- here rather than appending an ALTER TABLE migration.
-- One geniusrun account per OIDC subject. Every other table below is scoped -- One geniusrun account per OIDC subject. Every other table below is scoped
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/ -- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
-- sync_runs) or transitively through a JOIN to the owning row (laps/ -- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
-- activity_samples/kind_assignments/workout_type_paces, which have no -- activity_samples/kind_assignments/workout_type_paces, which have no
-- user_id column of their own since they're never queried except through a -- 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 ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE, oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- One row per user: Garmin credentials plus every tunable analysis-engine -- One row per user: Garmin credentials plus every tunable analysis-engine
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering, -- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
-- chart colors). store.ProvisionUser creates this (and everything else -- chart colors). store.ProvisionUser creates this (and everything else
-- below) in one transaction when a new account signs up. -- below) in one transaction when a new account signs up.
CREATE TABLE profile ( CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with -- 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 -- One row per lap/split (from get_activity_splits), plus HR drift/recovery
-- derived from activity_samples. No user_id column -- always accessed -- derived from activity_samples. No user_id column -- always accessed
-- through a specific owning activity. -- through a specific owning activity.
CREATE TABLE laps ( CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL, lap_index INTEGER NOT NULL,

View File

@@ -236,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// UNIQUE(user_id, name) allows the same name across two different users. // 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) t.Fatalf("insert users: %v", err)
} }
if _, err := db.ExecContext(ctx, ` 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. // 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) 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)") t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
} }
} }

View File

@@ -11,18 +11,18 @@ import (
// state) is scoped to exactly one User -- see // state) is scoped to exactly one User -- see
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md. // docs/superpowers/specs/2026-07-25-per-user-profile-design.md.
type User struct { type User struct {
ID int64 ID int64
OIDCSub string OIDCSub string
DisplayName string Name string
CreatedAt string CreatedAt string
} }
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key // GetUserBySub looks up a user by their OIDC subject -- the only lookup key
// the session-resolution middleware ever uses. // the session-resolution middleware ever uses.
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) { func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
var u User var u User
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return User{}, false, nil return User{}, false, nil
} }
@@ -63,7 +63,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
} }
defer tx.Rollback() 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 { if err != nil {
return 0, fmt.Errorf("create user %q: %w", oidcSub, err) 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 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) 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 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
}

View File

@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
if err != nil || !found { if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err) t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
} }
if u.ID != userID || u.DisplayName != "Lucie" { if u.ID != userID || u.Name != "Lucie" {
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
} }
profile, err := db.GetProfile(ctx, userID) profile, err := db.GetProfile(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) t.Fatalf("GetProfile: %v", err)
} }
if profile.Name != "Lucie" {
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
}
if profile.RollingWindowDays != 90 { if profile.RollingWindowDays != 90 {
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
} }
@@ -142,11 +139,11 @@ func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
query string query string
arg int64 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_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID}, {`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID}, {`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 activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID}, {`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID}, {`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},

View File

@@ -5,11 +5,11 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
## Tables ## Tables
- [`users`](#users) - [`users`](#users)
- [`profile`](#profile) - [`profiles`](#profiles)
- [`workout_kinds`](#workout_kinds) - [`workout_kinds`](#workout_kinds)
- [`workout_type_paces`](#workout_type_paces) - [`workout_type_paces`](#workout_type_paces)
- [`activities`](#activities) - [`activities`](#activities)
- [`laps`](#laps) - [`activity_laps`](#activity_laps)
- [`activity_samples`](#activity_samples) - [`activity_samples`](#activity_samples)
- [`kind_assignments`](#kind_assignments) - [`kind_assignments`](#kind_assignments)
- [`sync_state`](#sync_state) - [`sync_state`](#sync_state)
@@ -20,20 +20,19 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
```sql ```sql
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE, oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
``` ```
## `profile` ## `profiles`
```sql ```sql
CREATE TABLE profile ( CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with -- 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); CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
``` ```
## `laps` ## `activity_laps`
```sql ```sql
CREATE TABLE laps ( CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL, lap_index INTEGER NOT NULL,

View File

@@ -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 - 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 - don't put id_token in Claim, store it in the cookie apart from Claim
- find alternative to deprecated React.FormEvent - 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 - new workout kinds
- add "Recovery", "Quick", and "Sprint" workout kinds - add "Recovery", "Quick", and "Sprint" workout kinds
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race - order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race

View File

@@ -84,6 +84,7 @@ export function LoginGate() {
if (!session!.has_profile) { if (!session!.has_profile) {
return ( return (
<OnboardingWizard <OnboardingWizard
defaultName={session!.name}
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/> />
); );

View File

@@ -15,9 +15,17 @@ type Step = "name" | "garmin";
// database -- there's no half-created account to clean up or re-prompt // 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 // for later, so there's deliberately no "Log out" escape hatch anywhere in
// this flow: closing the tab *is* the escape hatch. // 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<Step>("name"); const [step, setStep] = useState<Step>("name");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState(defaultName);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");