diff --git a/CLAUDE.md b/CLAUDE.md index 55ca20c..f61db39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Backend (from `backend/`): - Single package: `go test ./internal/store/...` - Single test: `go test ./internal/store/... -run TestProfile -v` - Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`. -- Migrations live in `internal/store/migrations/`; add a new numbered file, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table). +- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync). Frontend (from `frontend/`): - Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`. @@ -36,9 +36,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install. -Upgrading a pre-per-user-profile install (binding the previously-singleton `profile`/`activities`/`workout_kinds`/`sync_state`/`sync_runs` rows to one real account) is a one-time step: set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to that account's OIDC subject before the first startup after upgrading — `store.ClaimLegacyOwner` runs once at boot, binds every row still marked `user_id IS NULL`, and is a permanent no-op afterward (safe to leave the env var set indefinitely). - -Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`), `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` (one-time upgrade bootstrap, see above). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). +Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). ## Repo layout @@ -46,11 +44,12 @@ Required env vars (`config.Load()` fails fast if any are unset, same pattern as backend/ cmd/geniusrund/ main server entrypoint cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds + cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql cmd/mcpspike/ throwaway MCP-client spike, safe to delete internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id) internal/garmin/mock/ fake Client for tests internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) - internal/store/ SQLite layer + embedded migrations; users.go/legacy_claim.go own account provisioning + the one-time upgrade bootstrap + internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning internal/sync/ orchestrates fetch -> store -> classify; one Service instance per user internal/api/ HTTP handlers (chi router); usercontext.go/setup.go own the per-user access boundary internal/config/ env var config loading (process-level only, not user-tunable params) @@ -102,9 +101,11 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c ## Data model conventions +See `docs/DATABASE.md` for the full, always-current schema (every table/column/index, regenerated via `go run ./cmd/dumpschema` -- see Commands above). The conventions below explain the *why* behind it; the doc itself is the ground truth for the *what*. + - **Every table is scoped to a `user_id`, one way or another.** `users` (keyed by OIDC subject) anchors it. `profile`, `workout_kinds`, `activities`, `sync_state`, `sync_runs` each carry their own `user_id` column and are queried with an explicit `WHERE user_id = ?`. `laps`, `activity_samples`, `kind_assignments`, and `workout_type_paces` have **no `user_id` column of their own** — they're always accessed through a specific owning row (an activity or a workout kind) via a `JOIN`/subquery back to that row's `user_id`, since they're never queried except through that owner. Every `internal/store` method that touches any of this takes an explicit `userID` parameter used in a real `WHERE`/`JOIN` clause — accepting the parameter without using it to filter would be a real cross-user leak, not a style nit. - `kind_assignments` is **append-only** — always INSERT, never UPDATE. Re-classifying after a rule edit, or a manual override, keeps full history; `current_kind_assignment` (a view) picks the latest row per activity by `id`. `assignment_source` distinguishes `manual` overrides (locked against future global reclassifies) from rule-engine assignments. -- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) were dropped from their own columns entirely (migration `0013_dedup_activity_lap_columns.sql`) and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone. +- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) are deliberately not modeled as their own columns and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone. - `(user_id, garmin_activity_id)` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass — scoped by user so two different users' Garmin accounts can never collide even if their activity IDs coincided. - `sync_state` (one row per user) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity **belonging to that user** (cascading to laps/samples/kind_assignments) and rewinds their watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched. - Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. Since `sync.Service` is one instance per user, this is naturally per-user too. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live. @@ -121,7 +122,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c - Table-driven Go tests throughout; test cases are inline, no separate fixture files. - `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly. -- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since migration/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call. +- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call. - `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`. - **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test. - The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints. diff --git a/backend/cmd/dumpschema/main.go b/backend/cmd/dumpschema/main.go new file mode 100644 index 0000000..0a1a0ab --- /dev/null +++ b/backend/cmd/dumpschema/main.go @@ -0,0 +1,101 @@ +// Command dumpschema regenerates docs/DATABASE.md from the real, live +// database schema -- it opens a fresh temp database through store.Open +// (the exact same code path geniusrund itself uses) and introspects +// sqlite_master, so the generated documentation can never drift from what +// the app actually creates. Run it after any change to +// internal/store/schema.sql: +// +// go run ./cmd/dumpschema +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "geniusrun/backend/internal/store" +) + +type schemaEntry struct { + typ string // "table" or "view" + name string + tblName string + sql string +} + +func main() { + tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema") + must(err) + defer os.RemoveAll(tmpDir) + + db, err := store.Open(filepath.Join(tmpDir, "schema-check.db")) + must(err) + defer db.Close() + + rows, err := db.Query(` + SELECT type, name, tbl_name, sql FROM sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' + ORDER BY rowid`) + must(err) + defer rows.Close() + + var tables, views []schemaEntry + indexesByTable := map[string][]schemaEntry{} + + for rows.Next() { + var e schemaEntry + must(rows.Scan(&e.typ, &e.name, &e.tblName, &e.sql)) + switch e.typ { + case "table": + tables = append(tables, e) + case "view": + views = append(views, e) + case "index": + indexesByTable[e.tblName] = append(indexesByTable[e.tblName], e) + } + } + must(rows.Err()) + + var b strings.Builder + b.WriteString("# geniusrun database schema\n\n") + b.WriteString("Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit. ") + b.WriteString("The source of truth is `backend/internal/store/schema.sql`; regenerate this file after changing it.\n\n") + + b.WriteString("## Tables\n\n") + for _, t := range tables { + fmt.Fprintf(&b, "- [`%s`](#%s)\n", t.name, t.name) + } + b.WriteString("\n") + + for _, t := range tables { + fmt.Fprintf(&b, "## `%s`\n\n", t.name) + fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", t.sql) + if idxs := indexesByTable[t.name]; len(idxs) > 0 { + b.WriteString("Indexes:\n\n```sql\n") + for _, idx := range idxs { + fmt.Fprintf(&b, "%s;\n", idx.sql) + } + b.WriteString("```\n\n") + } + } + + if len(views) > 0 { + b.WriteString("## Views\n\n") + for _, v := range views { + fmt.Fprintf(&b, "### `%s`\n\n", v.name) + fmt.Fprintf(&b, "```sql\n%s;\n```\n\n", v.sql) + } + } + + outPath := "../docs/DATABASE.md" + must(os.WriteFile(outPath, []byte(b.String()), 0644)) + log.Printf("wrote %s", outPath) +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index 8e25c03..318b8ee 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -31,12 +31,6 @@ func main() { } defer db.Close() - if cfg.LegacyOwnerOIDCSub != "" { - if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil { - log.Fatalf("claim legacy owner: %v", err) - } - } - authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ IssuerURL: cfg.OIDCIssuerURL, ClientID: cfg.OIDCClientID, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 944108c..1acd8ff 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -52,13 +52,6 @@ type Config struct { SessionSecret []byte SessionDuration time.Duration SessionSecure bool - - // LegacyOwnerOIDCSub, if set, is used exactly once at startup (via - // store.ClaimLegacyOwner) to bind this deployment's pre-existing - // single-tenant data to one named OIDC subject after upgrading to - // per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner - // no-ops once any user already exists. - LegacyOwnerOIDCSub string } // Load reads configuration from environment variables, applying defaults @@ -78,7 +71,6 @@ func Load() (Config, error) { OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), - LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"), } if cfg.GarminTokenStoreRoot == "" { diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go index 4862864..9a1ed24 100644 --- a/backend/internal/store/db.go +++ b/backend/internal/store/db.go @@ -4,25 +4,25 @@ package store import ( "database/sql" - "embed" + _ "embed" "fmt" - "io/fs" - "sort" _ "modernc.org/sqlite" ) -//go:embed migrations/*.sql -var migrationsFS embed.FS +//go:embed schema.sql +var schemaSQL string // DB wraps a *sql.DB opened against a geniusrun SQLite database file, with -// migrations already applied. +// the schema already applied. type DB struct { *sql.DB } // Open opens (creating if needed) the SQLite database at path and applies -// any migrations that haven't run yet. +// schema.sql if it hasn't been applied yet. There is no migration history -- +// this is a pre-production app with no compatibility obligation to older +// database files. Edit schema.sql directly to change the schema. func Open(path string) (*DB, error) { sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") if err != nil { @@ -33,110 +33,32 @@ func Open(path string) (*DB, error) { sqlDB.SetMaxOpenConns(1) db := &DB{DB: sqlDB} - if err := db.migrate(); err != nil { + if err := db.applySchema(); err != nil { sqlDB.Close() return nil, err } return db, nil } -func (db *DB) migrate() error { - if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( - filename TEXT PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - )`); err != nil { - return fmt.Errorf("create schema_migrations table: %w", err) +// applySchema runs schema.sql once, the first time this database file is +// opened -- detected by checking whether the users table already exists. +func (db *DB) applySchema() error { + var alreadyApplied int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&alreadyApplied); err != nil { + return fmt.Errorf("check existing schema: %w", err) + } + if alreadyApplied > 0 { + return nil } - applied := make(map[string]bool) - rows, err := db.Query(`SELECT filename FROM schema_migrations`) + tx, err := db.Begin() if err != nil { - return fmt.Errorf("query applied migrations: %w", err) + return fmt.Errorf("begin schema tx: %w", err) } - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - rows.Close() - return fmt.Errorf("scan applied migration: %w", err) - } - applied[name] = true + defer tx.Rollback() + + if _, err := tx.Exec(schemaSQL); err != nil { + return fmt.Errorf("apply schema: %w", err) } - rows.Close() - - entries, err := fs.Glob(migrationsFS, "migrations/*.sql") - if err != nil { - return fmt.Errorf("glob migrations: %w", err) - } - sort.Strings(entries) - - for _, entry := range entries { - name := entry[len("migrations/"):] - if applied[name] { - continue - } - content, err := migrationsFS.ReadFile(entry) - if err != nil { - return fmt.Errorf("read migration %s: %w", name, err) - } - - // Migrations 0023, 0024, 0025, and 0026 rebuild tables that have - // incoming foreign keys (kind_assignments/workout_type_paces - // reference workout_kinds; sync_state is referenced by - // activities/sync_runs; laps/activity_samples/kind_assignments - // reference activities). These require temporary FK disable in - // autocommit mode (before the transaction begins) so the DROP - // TABLE succeeds; a mid-transaction PRAGMA is a no-op with - // modernc.org/sqlite. - tableRebuildMigrations := map[string]bool{ - "0023_profile_user_scoped.sql": true, - "0024_workout_kinds_user_scoped.sql": true, - "0025_sync_state_user_scoped.sql": true, - "0026_activities_unique_constraint.sql": true, - } - needsFKToggle := tableRebuildMigrations[name] - - if needsFKToggle { - if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { - return fmt.Errorf("disable foreign keys before migration %s: %w", name, err) - } - } - - tx, err := db.Begin() - if err != nil { - if needsFKToggle { - db.Exec(`PRAGMA foreign_keys = ON`) - } - return fmt.Errorf("begin migration tx for %s: %w", name, err) - } - - if _, err := tx.Exec(string(content)); err != nil { - tx.Rollback() - if needsFKToggle { - db.Exec(`PRAGMA foreign_keys = ON`) - } - return fmt.Errorf("apply migration %s: %w", name, err) - } - - if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { - tx.Rollback() - if needsFKToggle { - db.Exec(`PRAGMA foreign_keys = ON`) - } - return fmt.Errorf("record migration %s: %w", name, err) - } - - if err := tx.Commit(); err != nil { - if needsFKToggle { - db.Exec(`PRAGMA foreign_keys = ON`) - } - return fmt.Errorf("commit migration %s: %w", name, err) - } - - if needsFKToggle { - if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { - return fmt.Errorf("enable foreign keys after migration %s: %w", name, err) - } - } - } - return nil + return tx.Commit() } diff --git a/backend/internal/store/legacy_claim.go b/backend/internal/store/legacy_claim.go deleted file mode 100644 index 6216765..0000000 --- a/backend/internal/store/legacy_claim.go +++ /dev/null @@ -1,54 +0,0 @@ -package store - -import ( - "context" - "fmt" -) - -// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime -// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left -// that way by migrations that can't take runtime parameters -- see -// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single -// new user identified by oidcSub. Safe to call on every startup: once the -// users table is non-empty, it's a no-op, so leaving -// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes -// no harm. -func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error { - var userCount int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { - return fmt.Errorf("count users: %w", err) - } - if userCount > 0 { - return nil // already bootstrapped (either claimed already, or real signups exist) - } - - var displayName string - err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName) - if err != nil { - return fmt.Errorf("find legacy profile: %w", err) - } - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin claim legacy owner tx: %w", err) - } - defer tx.Rollback() - - res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) - if err != nil { - return fmt.Errorf("create legacy owner user: %w", err) - } - userID, err := res.LastInsertId() - if err != nil { - return err - } - - // table is always one of the fixed literals below, never user input. - for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} { - if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil { - return fmt.Errorf("claim legacy %s rows: %w", table, err) - } - } - - return tx.Commit() -} diff --git a/backend/internal/store/legacy_claim_test.go b/backend/internal/store/legacy_claim_test.go deleted file mode 100644 index c267c8d..0000000 --- a/backend/internal/store/legacy_claim_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package store - -import ( - "context" - "testing" -) - -func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) { - db := openTestDB(t) - ctx := context.Background() - - // Simulate the pre-migration state: a fresh DB already has one - // migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds - // rows (user_id=NULL) -- exactly what a real upgraded deployment looks - // like right after Task 1's migrations run, before any user exists. - if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil { - t.Fatalf("seed legacy profile: %v", err) - } - if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil { - t.Fatalf("seed legacy activity: %v", err) - } - - if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { - t.Fatalf("ClaimLegacyOwner: %v", err) - } - - u, found, err := db.GetUserBySub(ctx, "kriss-sub") - if err != nil || !found { - t.Fatalf("GetUserBySub: found=%v err=%v", found, err) - } - if u.DisplayName != "Kriss" { - t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss") - } - - profile, err := db.GetProfile(ctx, u.ID) - if err != nil { - t.Fatalf("GetProfile(claimed user): %v", err) - } - if profile.GarminEmail != "kriss@example.com" { - t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail) - } - - kinds, err := db.ListWorkoutKinds(ctx, u.ID, false) - if err != nil { - t.Fatalf("ListWorkoutKinds(claimed user): %v", err) - } - if len(kinds) != 8 { - t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds)) - } - - activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{}) - if err != nil { - t.Fatalf("ListActivities(claimed user): %v", err) - } - if len(activities) != 1 { - t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities)) - } - - var remainingNullUserIDRows int - for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} { - var n int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil { - t.Fatalf("count NULL user_id in %s: %v", table, err) - } - remainingNullUserIDRows += n - } - if remainingNullUserIDRows != 0 { - t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows) - } -} - -func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) { - db := openTestDB(t) - ctx := context.Background() - - firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone") - if err != nil { - t.Fatalf("ProvisionUser: %v", err) - } - - // A second call (simulating a later restart with the env var still set) - // must not create a second user or touch anything. - if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { - t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err) - } - - if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found { - t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err) - } - users, err := db.ListUsers(ctx) - if err != nil { - t.Fatalf("ListUsers: %v", err) - } - if len(users) != 1 || users[0].ID != firstUserID { - t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users) - } -} diff --git a/backend/internal/store/migrations/0001_init.sql b/backend/internal/store/migrations/0001_init.sql deleted file mode 100644 index 77e96f3..0000000 --- a/backend/internal/store/migrations/0001_init.sql +++ /dev/null @@ -1,109 +0,0 @@ -CREATE TABLE activities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - garmin_activity_id INTEGER NOT NULL UNIQUE, - activity_name TEXT NOT NULL DEFAULT '', - activity_type TEXT NOT NULL, - start_time_utc TEXT NOT NULL, - begin_timestamp_ms INTEGER NOT NULL, - duration_seconds REAL NOT NULL, - distance_meters REAL NOT NULL, - avg_hr REAL, - max_hr REAL, - avg_speed_mps REAL, - max_speed_mps REAL, - elevation_gain_m REAL, - elevation_loss_m REAL, - calories REAL, - lap_count INTEGER NOT NULL DEFAULT 0, - aerobic_training_effect REAL, - anaerobic_training_effect REAL, - training_effect_label TEXT NOT NULL DEFAULT '', - vo2max_value REAL, - hr_time_in_zone_1 REAL, - hr_time_in_zone_2 REAL, - hr_time_in_zone_3 REAL, - hr_time_in_zone_4 REAL, - hr_time_in_zone_5 REAL, - raw_json TEXT NOT NULL, - details_fetched_at TEXT, - details_raw_json TEXT, - splits_fetched_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -CREATE INDEX idx_activities_start_time ON activities(start_time_utc); -CREATE INDEX idx_activities_activity_type ON activities(activity_type); - -CREATE TABLE laps ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, - lap_index INTEGER NOT NULL, - start_time_utc TEXT NOT NULL, - duration_seconds REAL NOT NULL, - distance_meters REAL NOT NULL, - avg_hr REAL, - max_hr REAL, - avg_speed_mps REAL, - max_speed_mps REAL, - elevation_gain_m REAL, - elevation_loss_m REAL, - intensity_type TEXT NOT NULL DEFAULT '', - hr_drift_bpm_per_min REAL, - hr_recovery_bpm_per_min REAL, - raw_json TEXT NOT NULL, - UNIQUE(activity_id, lap_index) -); - -CREATE TABLE activity_samples ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, - elapsed_seconds REAL NOT NULL, - timestamp_ms INTEGER NOT NULL, - heart_rate REAL, - speed_mps REAL, - distance_m REAL, - elevation_m REAL -); -CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds); - -CREATE TABLE workout_kinds ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - color TEXT NOT NULL DEFAULT '', - rule_json TEXT NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE kind_assignments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, - workout_kind_id INTEGER REFERENCES workout_kinds(id), - assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')), - status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')), - confidence REAL, - candidate_kinds_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); -CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id); -CREATE INDEX idx_kind_assignments_status ON kind_assignments(status); - -CREATE VIEW current_kind_assignment AS -SELECT a.* FROM kind_assignments a -JOIN ( - SELECT activity_id, MAX(id) AS max_id - FROM kind_assignments GROUP BY activity_id -) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id; - -CREATE TABLE sync_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')), - started_at TEXT NOT NULL, - finished_at TEXT, - activities_fetched INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL CHECK(status IN ('running','success','error')), - error_message TEXT -); diff --git a/backend/internal/store/migrations/0002_sync_state.sql b/backend/internal/store/migrations/0002_sync_state.sql deleted file mode 100644 index 2889300..0000000 --- a/backend/internal/store/migrations/0002_sync_state.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Tracks how far back a full backfill has already reached. Garmin activity --- history is immutable once recorded, so once we've backfilled a historical --- window there is no need to ever re-fetch get_activities() for it again -- --- this is the "cache" that lets repeated backfills be cheap/fast instead of --- re-walking years of history against Garmin's API every time. -CREATE TABLE sync_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - earliest_synced_date TEXT, - backfill_complete INTEGER NOT NULL DEFAULT 0 -); -INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0); diff --git a/backend/internal/store/migrations/0003_profile.sql b/backend/internal/store/migrations/0003_profile.sql deleted file mode 100644 index b30798a..0000000 --- a/backend/internal/store/migrations/0003_profile.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Single-profile settings: Garmin credentials (replacing env-var-only --- config) and every tunable engine parameter, in one editable row. -CREATE TABLE profile ( - id INTEGER PRIMARY KEY CHECK (id = 1), - garmin_email TEXT NOT NULL DEFAULT '', - garmin_password TEXT NOT NULL DEFAULT '', - rolling_window_days INTEGER NOT NULL DEFAULT 90, - max_heart_rate REAL, - resting_heart_rate REAL, - hr_zone1_min_pct REAL NOT NULL DEFAULT 50, - hr_zone1_max_pct REAL NOT NULL DEFAULT 60, - hr_zone2_min_pct REAL NOT NULL DEFAULT 60, - hr_zone2_max_pct REAL NOT NULL DEFAULT 70, - hr_zone3_min_pct REAL NOT NULL DEFAULT 70, - hr_zone3_max_pct REAL NOT NULL DEFAULT 80, - hr_zone4_min_pct REAL NOT NULL DEFAULT 80, - hr_zone4_max_pct REAL NOT NULL DEFAULT 90, - hr_zone5_min_pct REAL NOT NULL DEFAULT 90, - hr_zone5_max_pct REAL NOT NULL DEFAULT 100, - easy_warmup_minutes REAL NOT NULL DEFAULT 10, - easy_cooldown_minutes REAL NOT NULL DEFAULT 5, - long_warmup_minutes REAL NOT NULL DEFAULT 10, - long_cooldown_minutes REAL NOT NULL DEFAULT 5, - tempo_warmup_minutes REAL NOT NULL DEFAULT 15, - tempo_cooldown_minutes REAL NOT NULL DEFAULT 10, - threshold30_warmup_minutes REAL NOT NULL DEFAULT 15, - threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10, - threshold60_warmup_minutes REAL NOT NULL DEFAULT 15, - threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10, - mas_test_warmup_minutes REAL NOT NULL DEFAULT 15, - mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5, - interval_warmup_minutes REAL NOT NULL DEFAULT 0, - interval_cooldown_minutes REAL NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -INSERT INTO profile (id) VALUES (1); diff --git a/backend/internal/store/migrations/0004_workout_taxonomy.sql b/backend/internal/store/migrations/0004_workout_taxonomy.sql deleted file mode 100644 index 5545a99..0000000 --- a/backend/internal/store/migrations/0004_workout_taxonomy.sql +++ /dev/null @@ -1,17 +0,0 @@ --- The taxonomy is now a fixed, closed set (no more user-created "kinds"). --- Any prior arbitrary kinds and their classification history are reset: --- the concept they classified against no longer exists. -DELETE FROM kind_assignments; -DELETE FROM workout_kinds; - --- Placeholder rule: distance is never negative, so this never matches. --- Every activity starts in needs_review for every type until real rules --- are tuned (a follow-up plan, not this migration). -INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES - ('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), - ('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1); diff --git a/backend/internal/store/migrations/0005_workout_type_paces.sql b/backend/internal/store/migrations/0005_workout_type_paces.sql deleted file mode 100644 index 9a2ead7..0000000 --- a/backend/internal/store/migrations/0005_workout_type_paces.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Per-workout-type target pace range and expected HR zone. Informational --- only: never read by the classification rule engine (see spec section 2). --- No history -- overwritten in place when the user updates a value; the --- synced activity log is the historical record. -CREATE TABLE workout_type_paces ( - workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), - pace_min_sec_per_km REAL, - pace_max_sec_per_km REAL, - expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5) -); - -INSERT INTO workout_type_paces (workout_kind_id) -SELECT id FROM workout_kinds; diff --git a/backend/internal/store/migrations/0006_global_phase_minutes.sql b/backend/internal/store/migrations/0006_global_phase_minutes.sql deleted file mode 100644 index b866419..0000000 --- a/backend/internal/store/migrations/0006_global_phase_minutes.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Phase-detection warm-up/cool-down was originally one setting per workout --- type (14 columns: 6 types x 2, plus an unused Interval pair -- Interval --- detects phases from lap data directly, never from a fixed duration). That --- turned out to be more granularity than wanted: replaced with a single --- global warm-up/cool-down pair applied to every fixed-duration workout type. -ALTER TABLE profile ADD COLUMN warmup_minutes REAL NOT NULL DEFAULT 10; -ALTER TABLE profile ADD COLUMN cooldown_minutes REAL NOT NULL DEFAULT 5; - -ALTER TABLE profile DROP COLUMN easy_warmup_minutes; -ALTER TABLE profile DROP COLUMN easy_cooldown_minutes; -ALTER TABLE profile DROP COLUMN long_warmup_minutes; -ALTER TABLE profile DROP COLUMN long_cooldown_minutes; -ALTER TABLE profile DROP COLUMN tempo_warmup_minutes; -ALTER TABLE profile DROP COLUMN tempo_cooldown_minutes; -ALTER TABLE profile DROP COLUMN threshold30_warmup_minutes; -ALTER TABLE profile DROP COLUMN threshold30_cooldown_minutes; -ALTER TABLE profile DROP COLUMN threshold60_warmup_minutes; -ALTER TABLE profile DROP COLUMN threshold60_cooldown_minutes; -ALTER TABLE profile DROP COLUMN mas_test_warmup_minutes; -ALTER TABLE profile DROP COLUMN mas_test_cooldown_minutes; -ALTER TABLE profile DROP COLUMN interval_warmup_minutes; -ALTER TABLE profile DROP COLUMN interval_cooldown_minutes; diff --git a/backend/internal/store/migrations/0007_race_kind.sql b/backend/internal/store/migrations/0007_race_kind.sql deleted file mode 100644 index 5b507c3..0000000 --- a/backend/internal/store/migrations/0007_race_kind.sql +++ /dev/null @@ -1,12 +0,0 @@ --- "Race" is an 8th fixed workout kind. Unlike the other 7 (seeded with a --- never-matching placeholder rule pending manual tuning), it gets a real --- rule from day one: Garmin Connect lets a user manually tag an activity's --- event type as "Race", and that value round-trips through get_activities() --- as eventType.typeKey -- a genuine, deterministic signal, not a guess. -ALTER TABLE activities ADD COLUMN event_type_key TEXT NOT NULL DEFAULT ''; - -INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES - ('Race', '', '#dc2626', '{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}', 0, 1); - -INSERT INTO workout_type_paces (workout_kind_id) -SELECT id FROM workout_kinds WHERE name = 'Race'; diff --git a/backend/internal/store/migrations/0008_workout_targets.sql b/backend/internal/store/migrations/0008_workout_targets.sql deleted file mode 100644 index 6356808..0000000 --- a/backend/internal/store/migrations/0008_workout_targets.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Structured Garmin workouts (created in Garmin Connect or a training plan --- tool) attach a per-step target pace/HR zone. An activity recorded from one --- carries that workout's id; when its laps line up 1:1 with the workout's --- flattened steps (see internal/sync's alignWorkoutTargets), the expected --- band is resolved and stored per lap so the Review Queue can plot expected --- vs actual pace/HR without a live Garmin call on every page view. -ALTER TABLE activities ADD COLUMN workout_id INTEGER; - -ALTER TABLE laps ADD COLUMN target_pace_low_mps REAL; -ALTER TABLE laps ADD COLUMN target_pace_high_mps REAL; -ALTER TABLE laps ADD COLUMN target_hr_low_bpm REAL; -ALTER TABLE laps ADD COLUMN target_hr_high_bpm REAL; diff --git a/backend/internal/store/migrations/0009_backfill_horizon.sql b/backend/internal/store/migrations/0009_backfill_horizon.sql deleted file mode 100644 index 7cda3fa..0000000 --- a/backend/internal/store/migrations/0009_backfill_horizon.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Backfill horizon used to be a startup-time env var --- (GENIUSRUN_BACKFILL_HORIZON_DAYS) with no UI at all -- exactly the kind of --- "tunable analysis-engine parameter" this profile table exists for. --- Default matches the old env var's default (3 years) so existing --- deployments keep their current behavior until the user changes it. -ALTER TABLE profile ADD COLUMN backfill_horizon_days INTEGER NOT NULL DEFAULT 1095; diff --git a/backend/internal/store/migrations/0010_pace_artifact_filter.sql b/backend/internal/store/migrations/0010_pace_artifact_filter.sql deleted file mode 100644 index 517647d..0000000 --- a/backend/internal/store/migrations/0010_pace_artifact_filter.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as --- recording starts, before the run itself begins) out of the Review --- Queue's pace chart: a stretch of samples slower than --- min_representative_pace_sec_per_km is dropped unless it persists for at --- least min_representative_time_seconds, in which case it's treated as a --- real stop or walk break, not noise. Defaults match the values used to --- design this feature (12:00/km, 3 seconds). -ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720; -ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3; diff --git a/backend/internal/store/migrations/0011_profile_name.sql b/backend/internal/store/migrations/0011_profile_name.sql deleted file mode 100644 index 3efa4be..0000000 --- a/backend/internal/store/migrations/0011_profile_name.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Names the profile so a future multi-profile setup can show which one is --- active. Only one profile row exists today (id=1), so this is just a label. -ALTER TABLE profile ADD COLUMN name TEXT NOT NULL DEFAULT 'Default'; diff --git a/backend/internal/store/migrations/0012_workout_type_hr_range.sql b/backend/internal/store/migrations/0012_workout_type_hr_range.sql deleted file mode 100644 index 1bd39da..0000000 --- a/backend/internal/store/migrations/0012_workout_type_hr_range.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Replaces the named-zone-only expected_hr_zone with a custom %HRR range --- per training type (e.g. "easy runs at 70-80% heart rate reserve"), --- matching how pace range is already modeled. expected_hr_zone is left in --- place but unused going forward -- nothing reads or writes it anymore. -ALTER TABLE workout_type_paces ADD COLUMN hr_min_pct_hrr REAL; -ALTER TABLE workout_type_paces ADD COLUMN hr_max_pct_hrr REAL; diff --git a/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql b/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql deleted file mode 100644 index f5137f2..0000000 --- a/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Phase 1 of the raw-JSON duplication cleanup: drops every Activity/Lap --- column that was a pure untransformed copy of a value already present in --- that row's own raw_json, with zero SQL/classify/functional consumer --- anywhere in the app (see the 2026-07 field-by-field duplication audit). --- Unlike this project's usual additive-only migrations, dropping these --- columns outright is the whole point of this one -- leaving them inert --- would keep the exact duplication being removed. activity_name/ --- activity_type (Activity) and duration_seconds/avg_hr (laps) also go here --- even though the frontend still displays them: they're now decoded from --- raw_json at API-response time instead of stored separately (see --- internal/api's decodeActivityDisplayFields/decodeLapDisplayFields). -DROP INDEX idx_activities_activity_type; - -ALTER TABLE activities DROP COLUMN activity_name; -ALTER TABLE activities DROP COLUMN activity_type; -ALTER TABLE activities DROP COLUMN begin_timestamp_ms; -ALTER TABLE activities DROP COLUMN max_speed_mps; -ALTER TABLE activities DROP COLUMN elevation_loss_m; -ALTER TABLE activities DROP COLUMN calories; -ALTER TABLE activities DROP COLUMN lap_count; -ALTER TABLE activities DROP COLUMN training_effect_label; -ALTER TABLE activities DROP COLUMN hr_time_in_zone_1; -ALTER TABLE activities DROP COLUMN hr_time_in_zone_2; -ALTER TABLE activities DROP COLUMN hr_time_in_zone_3; -ALTER TABLE activities DROP COLUMN hr_time_in_zone_4; -ALTER TABLE activities DROP COLUMN hr_time_in_zone_5; - -ALTER TABLE laps DROP COLUMN start_time_utc; -ALTER TABLE laps DROP COLUMN duration_seconds; -ALTER TABLE laps DROP COLUMN distance_meters; -ALTER TABLE laps DROP COLUMN avg_hr; -ALTER TABLE laps DROP COLUMN max_hr; -ALTER TABLE laps DROP COLUMN max_speed_mps; -ALTER TABLE laps DROP COLUMN elevation_gain_m; -ALTER TABLE laps DROP COLUMN elevation_loss_m; diff --git a/backend/internal/store/migrations/0014_chart_colors.sql b/backend/internal/store/migrations/0014_chart_colors.sql deleted file mode 100644 index 6e9e2d2..0000000 --- a/backend/internal/store/migrations/0014_chart_colors.sql +++ /dev/null @@ -1,10 +0,0 @@ --- User-configurable chart colors: 2 "main line" colors (one per metric) and --- 4 "effort kind" colors (one per workout phase), used together to derive --- the pace/HR chart's line, under-the-line fill, and phase-background fill --- colors (see frontend's ExpectedVsActualChart). -ALTER TABLE profile ADD COLUMN pace_color TEXT NOT NULL DEFAULT '#3b82f6'; -ALTER TABLE profile ADD COLUMN heart_rate_color TEXT NOT NULL DEFAULT '#ef4444'; -ALTER TABLE profile ADD COLUMN warmup_color TEXT NOT NULL DEFAULT '#c2410c'; -ALTER TABLE profile ADD COLUMN effort_color TEXT NOT NULL DEFAULT '#7c3aed'; -ALTER TABLE profile ADD COLUMN recovery_color TEXT NOT NULL DEFAULT '#15803d'; -ALTER TABLE profile ADD COLUMN cooldown_color TEXT NOT NULL DEFAULT '#fb923c'; diff --git a/backend/internal/store/migrations/0015_training_type_names_and_order.sql b/backend/internal/store/migrations/0015_training_type_names_and_order.sql deleted file mode 100644 index 58ff10f..0000000 --- a/backend/internal/store/migrations/0015_training_type_names_and_order.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Renames the default training-type taxonomy to a fixed display convention --- (drop the redundant "Run" suffix, numeral before "Threshold", "Intervals" --- not "Interval") and assigns explicit priorities so kinds always list in --- this exact order wherever they're shown (Activities filters, Progression's --- kind picker, Profile's Training types card) -- existing ORDER BY priority --- DESC, name already does the sorting, no query changes needed: --- Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race. --- --- Each UPDATE matches on the original seeded name, so a kind the user has --- already renamed themselves (no longer matching) is left untouched. -UPDATE workout_kinds SET name = 'Easy', priority = 80 WHERE name = 'Easy Run'; -UPDATE workout_kinds SET name = 'Long', priority = 70 WHERE name = 'Long Run'; -UPDATE workout_kinds SET name = '60'' Threshold', priority = 60 WHERE name = 'Threshold 60'''; -UPDATE workout_kinds SET name = '30'' Threshold', priority = 50 WHERE name = 'Threshold 30'''; -UPDATE workout_kinds SET priority = 40 WHERE name = 'Tempo'; -UPDATE workout_kinds SET name = 'Intervals', priority = 30 WHERE name = 'Interval'; -UPDATE workout_kinds SET priority = 20 WHERE name = 'MAS Test'; -UPDATE workout_kinds SET priority = 10 WHERE name = 'Race'; diff --git a/backend/internal/store/migrations/0016_chart_tint_intensity.sql b/backend/internal/store/migrations/0016_chart_tint_intensity.sql deleted file mode 100644 index c22c8ff..0000000 --- a/backend/internal/store/migrations/0016_chart_tint_intensity.sql +++ /dev/null @@ -1,5 +0,0 @@ --- How strongly the main line color (blue/red) tints the effort-kind fill --- under the line, as a percentage (0-100) mixed in -- see frontend's --- ExpectedVsActualChart mixColor(). The phase background above the line is --- never tinted by the main line color regardless of this setting. -ALTER TABLE profile ADD COLUMN main_line_tint_pct REAL NOT NULL DEFAULT 20; diff --git a/backend/internal/store/migrations/0017_chart_background_darken.sql b/backend/internal/store/migrations/0017_chart_background_darken.sql deleted file mode 100644 index 0a03607..0000000 --- a/backend/internal/store/migrations/0017_chart_background_darken.sql +++ /dev/null @@ -1,5 +0,0 @@ --- How strongly (0-100) the effort-kind color is darkened for the phase --- background above the line -- see frontend's ExpectedVsActualChart --- darken(). Never mixed with the main line color, unlike the fill below the --- line (see main_line_tint_pct). -ALTER TABLE profile ADD COLUMN background_darken_pct REAL NOT NULL DEFAULT 35; diff --git a/backend/internal/store/migrations/0018_chart_target_brighten.sql b/backend/internal/store/migrations/0018_chart_target_brighten.sql deleted file mode 100644 index 25caab9..0000000 --- a/backend/internal/store/migrations/0018_chart_target_brighten.sql +++ /dev/null @@ -1,5 +0,0 @@ --- How strongly (0-100) a chart's main line color (and everything tinted --- from it) is brightened when that chart actually has a structured-workout --- target range to show -- a color-based cue that a target is present, --- replacing a text label -- see frontend's ExpectedVsActualChart brighten(). -ALTER TABLE profile ADD COLUMN target_brighten_pct REAL NOT NULL DEFAULT 20; diff --git a/backend/internal/store/migrations/0019_activity_workout_raw_json.sql b/backend/internal/store/migrations/0019_activity_workout_raw_json.sql deleted file mode 100644 index df4c0df..0000000 --- a/backend/internal/store/migrations/0019_activity_workout_raw_json.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Genuine raw JSON of the activity's structured Garmin workout (get_workout_by_id), --- the source used to compute each lap's TargetPaceLowMps/HighMps and --- TargetHRLowBpm/HighBpm (see internal/sync/mapping.go's alignWorkoutTargets). --- Null for activities with no WorkoutID, or synced before this column existed. -ALTER TABLE activities ADD COLUMN workout_raw_json TEXT; diff --git a/backend/internal/store/migrations/0020_sync_runs_full_kind.sql b/backend/internal/store/migrations/0020_sync_runs_full_kind.sql deleted file mode 100644 index 6ea2f47..0000000 --- a/backend/internal/store/migrations/0020_sync_runs_full_kind.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Widens sync_runs.kind's CHECK constraint to also allow 'full' (a manual --- "Sync now" pass recorded as one combined run instead of separate --- backfill/incremental rows -- see internal/sync.Service.FullSync). SQLite --- has no ALTER TABLE for CHECK constraints, so the table is rebuilt. -CREATE TABLE sync_runs_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), - started_at TEXT NOT NULL, - finished_at TEXT, - activities_fetched INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL CHECK(status IN ('running','success','error')), - error_message TEXT -); - -INSERT INTO sync_runs_new (id, kind, started_at, finished_at, activities_fetched, status, error_message) -SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message FROM sync_runs; - -DROP TABLE sync_runs; -ALTER TABLE sync_runs_new RENAME TO sync_runs; diff --git a/backend/internal/store/migrations/0021_users_table.sql b/backend/internal/store/migrations/0021_users_table.sql deleted file mode 100644 index 47c00ee..0000000 --- a/backend/internal/store/migrations/0021_users_table.sql +++ /dev/null @@ -1,6 +0,0 @@ -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')) -); diff --git a/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql b/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql deleted file mode 100644 index a7abe08..0000000 --- a/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql +++ /dev/null @@ -1,15 +0,0 @@ --- user_id is nullable here even though every row will eventually need one: --- migrations can't take runtime parameters, so the actual owner isn't known --- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at --- geniusrund startup) backfills every existing row to one user once given --- that user's OIDC subject; from then on every store method requires a --- non-nil userID and this column is never NULL again in practice. -ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id); -ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id); - --- Used by UpsertActivity's ON CONFLICT target going forward. The original --- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too --- -- Garmin's own activity ids are already globally unique in practice, so --- the stricter constraint is harmless, and SQLite can't drop a column-level --- constraint without a full table rebuild, which isn't worth the risk here. -CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id); diff --git a/backend/internal/store/migrations/0023_profile_user_scoped.sql b/backend/internal/store/migrations/0023_profile_user_scoped.sql deleted file mode 100644 index fb6ec01..0000000 --- a/backend/internal/store/migrations/0023_profile_user_scoped.sql +++ /dev/null @@ -1,76 +0,0 @@ --- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be --- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this --- rebuilds the table via SQLite's documented rename/recreate/copy/drop --- pattern instead. Always create the replacement under a different name and --- RENAME it into place at the end (never rename the live table away first) --- -- verified directly against SQLite that this ordering is what keeps --- other tables' foreign keys intact when they exist (not the case for --- profile, but kept consistent with migrations 0024/0025 for the same --- pattern). user_id is nullable for the same not-yet-known-owner reason as --- migration 0022 -- see its comment. -CREATE TABLE profile_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - name TEXT NOT NULL DEFAULT 'Default', - garmin_email TEXT NOT NULL DEFAULT '', - garmin_password TEXT NOT NULL DEFAULT '', - rolling_window_days INTEGER NOT NULL DEFAULT 90, - backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, - max_heart_rate REAL, - resting_heart_rate REAL, - hr_zone1_min_pct REAL NOT NULL DEFAULT 50, - hr_zone1_max_pct REAL NOT NULL DEFAULT 60, - hr_zone2_min_pct REAL NOT NULL DEFAULT 60, - hr_zone2_max_pct REAL NOT NULL DEFAULT 70, - hr_zone3_min_pct REAL NOT NULL DEFAULT 70, - hr_zone3_max_pct REAL NOT NULL DEFAULT 80, - hr_zone4_min_pct REAL NOT NULL DEFAULT 80, - hr_zone4_max_pct REAL NOT NULL DEFAULT 90, - hr_zone5_min_pct REAL NOT NULL DEFAULT 90, - hr_zone5_max_pct REAL NOT NULL DEFAULT 100, - warmup_minutes REAL NOT NULL DEFAULT 10, - cooldown_minutes REAL NOT NULL DEFAULT 5, - min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720, - min_representative_time_seconds REAL NOT NULL DEFAULT 3, - pace_color TEXT NOT NULL DEFAULT '#3b82f6', - heart_rate_color TEXT NOT NULL DEFAULT '#ef4444', - warmup_color TEXT NOT NULL DEFAULT '#c2410c', - effort_color TEXT NOT NULL DEFAULT '#7c3aed', - recovery_color TEXT NOT NULL DEFAULT '#15803d', - cooldown_color TEXT NOT NULL DEFAULT '#fb923c', - main_line_tint_pct REAL NOT NULL DEFAULT 20, - background_darken_pct REAL NOT NULL DEFAULT 35, - target_brighten_pct REAL NOT NULL DEFAULT 20, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id) -); - -INSERT INTO profile_new ( - id, user_id, name, 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, - warmup_minutes, cooldown_minutes, - min_representative_pace_sec_per_km, min_representative_time_seconds, - pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, - main_line_tint_pct, background_darken_pct, target_brighten_pct, - created_at, updated_at -) -SELECT - id, NULL, name, 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, - warmup_minutes, cooldown_minutes, - min_representative_pace_sec_per_km, min_representative_time_seconds, - pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, - main_line_tint_pct, background_darken_pct, target_brighten_pct, - created_at, updated_at -FROM profile; - -DROP TABLE profile; - -ALTER TABLE profile_new RENAME TO profile; diff --git a/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql b/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql deleted file mode 100644 index e54e54f..0000000 --- a/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql +++ /dev/null @@ -1,31 +0,0 @@ --- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become --- per-user (UNIQUE(user_id, name)) now that every user gets their own copy --- of the same 8-kind taxonomy -- otherwise a second user could never be --- provisioned (inserting the same seeded name would collide). kind_assignments --- and workout_type_paces hold foreign keys into this table --- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/ --- copy/drop-old/rename-into-place order below (verified against a real --- SQLite database) leaves those foreign keys' schema text untouched --- throughout, so they resolve correctly again the instant the final --- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes. -CREATE TABLE workout_kinds_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - color TEXT NOT NULL DEFAULT '', - rule_json TEXT NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, name) -); - -INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at) -SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at -FROM workout_kinds; - -DROP TABLE workout_kinds; - -ALTER TABLE workout_kinds_new RENAME TO workout_kinds; diff --git a/backend/internal/store/migrations/0025_sync_state_user_scoped.sql b/backend/internal/store/migrations/0025_sync_state_user_scoped.sql deleted file mode 100644 index 421ecd9..0000000 --- a/backend/internal/store/migrations/0025_sync_state_user_scoped.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild. -CREATE TABLE sync_state_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - earliest_synced_date TEXT, - backfill_complete INTEGER NOT NULL DEFAULT 0, - UNIQUE(user_id) -); - -INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete) -SELECT id, NULL, earliest_synced_date, backfill_complete -FROM sync_state; - -DROP TABLE sync_state; - -ALTER TABLE sync_state_new RENAME TO sync_state; diff --git a/backend/internal/store/migrations/0026_activities_unique_constraint.sql b/backend/internal/store/migrations/0026_activities_unique_constraint.sql deleted file mode 100644 index d185a29..0000000 --- a/backend/internal/store/migrations/0026_activities_unique_constraint.sql +++ /dev/null @@ -1,55 +0,0 @@ --- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become --- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile --- design allows multiple users to share the same Garmin activity ID. The old --- constraint alone was intentionally left in place by migration 0022 as a --- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice), --- but it must be removed now to allow Task 6's cross-user test to pass. --- --- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table --- with real inbound foreign keys) happens in Go code around this migration's --- execution (db.go's tableRebuildMigrations map), in autocommit mode before --- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement --- is a documented no-op with modernc.org/sqlite, so it must not appear here. --- --- event_type_key keeps the DEFAULT '' that migration 0007 originally gave --- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT ''); --- dropping the default here (as an earlier draft of this migration did) --- broke every INSERT that omits event_type_key and relies on that default, --- which several existing tests (e.g. TestClaimLegacyOwner_*) do. - -CREATE TABLE activities_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users(id), - garmin_activity_id INTEGER NOT NULL, - event_type_key TEXT NOT NULL DEFAULT '', - workout_id INTEGER, - start_time_utc TEXT NOT NULL, - duration_seconds REAL NOT NULL, - distance_meters REAL NOT NULL, - avg_hr REAL, - max_hr REAL, - avg_speed_mps REAL, - elevation_gain_m REAL, - aerobic_training_effect REAL, - anaerobic_training_effect REAL, - vo2max_value REAL, - raw_json TEXT NOT NULL, - details_fetched_at TEXT, - details_raw_json TEXT, - splits_fetched_at TEXT, - workout_raw_json TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, garmin_activity_id) -); - -INSERT INTO activities_new (id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at) -SELECT id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at -FROM activities; - -DROP TABLE activities; - -ALTER TABLE activities_new RENAME TO activities; - -CREATE INDEX idx_activities_start_time ON activities(start_time_utc); -CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id); diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql new file mode 100644 index 0000000..2c3c09c --- /dev/null +++ b/backend/internal/store/schema.sql @@ -0,0 +1,239 @@ +-- geniusrun's complete SQLite schema, applied in full on every Open() (see +-- db.go). There is no migration history: this file is regenerated in place +-- whenever the schema changes, and is the single source of truth for both +-- the app and its documentation (see docs/DATABASE.md, generated from this +-- file's live effect via cmd/dumpschema -- regenerate it after editing this +-- file). This is a pre-production app with no compatibility obligation to +-- older database files; if you need to change a column, edit it directly +-- 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/ +-- 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). +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')) +); + +-- 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'Default', + garmin_email TEXT NOT NULL DEFAULT '', + garmin_password TEXT NOT NULL DEFAULT '', + rolling_window_days INTEGER NOT NULL DEFAULT 90, + -- BackfillHorizonDays bounds how far back "Sync now" reaches when + -- walking backward from today; read fresh on every sync (not cached at + -- server startup), so changing it here takes effect on the next click. + backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, + max_heart_rate REAL, + resting_heart_rate REAL, + hr_zone1_min_pct REAL NOT NULL DEFAULT 50, + hr_zone1_max_pct REAL NOT NULL DEFAULT 60, + hr_zone2_min_pct REAL NOT NULL DEFAULT 60, + hr_zone2_max_pct REAL NOT NULL DEFAULT 70, + hr_zone3_min_pct REAL NOT NULL DEFAULT 70, + hr_zone3_max_pct REAL NOT NULL DEFAULT 80, + hr_zone4_min_pct REAL NOT NULL DEFAULT 80, + hr_zone4_max_pct REAL NOT NULL DEFAULT 90, + hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone5_max_pct REAL NOT NULL DEFAULT 100, + -- Applies uniformly to every fixed-duration workout type's phase + -- detection (Easy, Long, Tempo, Threshold 30'/60', MAS Test). Interval + -- workouts detect phases from lap data directly and don't use these. + warmup_minutes REAL NOT NULL DEFAULT 10, + cooldown_minutes REAL NOT NULL DEFAULT 5, + -- Review Queue pace-chart artifact filter: a stretch of samples slower + -- than min_representative_pace_sec_per_km is dropped unless it persists + -- for at least min_representative_time_seconds, in which case it's a + -- real stop/walk break rather than GPS/motion noise at recording start. + min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720, + min_representative_time_seconds REAL NOT NULL DEFAULT 3, + -- Chart colors: pace_color/heart_rate_color are each chart's "main + -- line" color; warmup/effort/recovery/cooldown_color are the "effort + -- kind" colors the frontend derives fills from (see + -- ExpectedVsActualChart). + pace_color TEXT NOT NULL DEFAULT '#3b82f6', + heart_rate_color TEXT NOT NULL DEFAULT '#ef4444', + warmup_color TEXT NOT NULL DEFAULT '#c2410c', + effort_color TEXT NOT NULL DEFAULT '#7c3aed', + recovery_color TEXT NOT NULL DEFAULT '#15803d', + cooldown_color TEXT NOT NULL DEFAULT '#fb923c', + main_line_tint_pct REAL NOT NULL DEFAULT 20, + background_darken_pct REAL NOT NULL DEFAULT 35, + target_brighten_pct REAL NOT NULL DEFAULT 20, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id) +); + +-- The fixed, closed 8-type taxonomy (Easy, Long, 60' Threshold, +-- 30' Threshold, Tempo, Intervals, MAS Test, Race) -- one independently +-- tunable copy per user, seeded by store.ProvisionUser. rule_json holds the +-- recursive AND/OR condition tree evaluated by internal/classify. +CREATE TABLE workout_kinds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, name) +); + +-- Each workout kind's user-declared target pace range + HR range (percent +-- of heart rate reserve). Informational only -- never read by the +-- classification rule engine. No history: overwritten in place, since the +-- synced activity log is the history. No user_id column of its own -- +-- ownership is checked via a JOIN to workout_kinds. +CREATE TABLE workout_type_paces ( + workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), + pace_min_sec_per_km REAL, + pace_max_sec_per_km REAL, + hr_min_pct_hrr REAL, + hr_max_pct_hrr REAL +); + +-- One row per synced Garmin activity. garmin_activity_id is the natural +-- idempotency key for UpsertActivity's ON CONFLICT, scoped per user so two +-- different users' Garmin accounts can never collide even if their +-- activity IDs coincided. raw_json/details_raw_json/workout_raw_json store +-- the full original Garmin JSON -- fields that are a pure untransformed +-- copy of something already in raw_json (activity_name, activity_type, +-- etc.) are deliberately NOT modeled as their own columns; internal/api's +-- display_fields.go decodes them fresh from raw_json at response time +-- instead of storing a redundant copy. +CREATE TABLE activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + garmin_activity_id INTEGER NOT NULL, + -- Derived at sync time from Garmin's own eventType.typeKey=="race" -- + -- a hard fact, not a rule the user tunes (see internal/classify's + -- is_race metric and handleReclassifyAll's Race special-case). + event_type_key TEXT NOT NULL DEFAULT '', + -- Set when the activity was recorded from a structured Garmin workout; + -- used to resolve each lap's expected pace/HR band (see + -- internal/sync/mapping.go's alignWorkoutTargets). + workout_id INTEGER, + start_time_utc TEXT NOT NULL, + duration_seconds REAL NOT NULL, + distance_meters REAL NOT NULL, + avg_hr REAL, + max_hr REAL, + avg_speed_mps REAL, + elevation_gain_m REAL, + aerobic_training_effect REAL, + anaerobic_training_effect REAL, + vo2max_value REAL, + raw_json TEXT NOT NULL, + details_fetched_at TEXT, + details_raw_json TEXT, + splits_fetched_at TEXT, + -- Genuine raw get_workout_by_id() response, the source used to compute + -- alignWorkoutTargets. Null when the activity has no workout_id. + workout_raw_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, garmin_activity_id) +); +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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + lap_index INTEGER NOT NULL, + avg_speed_mps REAL, + intensity_type TEXT NOT NULL DEFAULT '', + hr_drift_bpm_per_min REAL, + hr_recovery_bpm_per_min REAL, + -- Expected band for this lap, resolved from the activity's structured + -- Garmin workout when its steps line up 1:1 with the recorded laps. + -- Null when there's no structured workout or the counts don't match. + target_pace_low_mps REAL, + target_pace_high_mps REAL, + target_hr_low_bpm REAL, + target_hr_high_bpm REAL, + raw_json TEXT NOT NULL, + UNIQUE(activity_id, lap_index) +); + +-- One row per ~1-second telemetry sample (from get_activity_details). No +-- user_id column -- always accessed through a specific owning activity. +CREATE TABLE activity_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + elapsed_seconds REAL NOT NULL, + timestamp_ms INTEGER NOT NULL, + heart_rate REAL, + speed_mps REAL, + distance_m REAL, + elevation_m REAL +); +CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds); + +-- Append-only classification history -- always INSERT, never UPDATE. +-- Re-classifying after a rule edit, or a manual override, keeps full +-- history; current_kind_assignment (below) picks the latest row per +-- activity. assignment_source distinguishes manual overrides (locked +-- against future global reclassifies) from rule-engine assignments. No +-- user_id column -- always accessed through the owning activity. +CREATE TABLE kind_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + workout_kind_id INTEGER REFERENCES workout_kinds(id), + assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')), + status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')), + confidence REAL, + candidate_kinds_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id); +CREATE INDEX idx_kind_assignments_status ON kind_assignments(status); + +CREATE VIEW current_kind_assignment AS +SELECT a.* FROM kind_assignments a +JOIN ( + SELECT activity_id, MAX(id) AS max_id + FROM kind_assignments GROUP BY activity_id +) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id; + +-- One row per user: tracks a backfill watermark (earliest_synced_date, +-- backfill_complete). Since Garmin history is immutable once recorded, +-- Service.Backfill uses this to resume from where it left off instead of +-- re-walking years of already-known history on every call. +CREATE TABLE sync_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + earliest_synced_date TEXT, + backfill_complete INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id) +); + +-- One row per backfill/incremental/full sync attempt, for the "last sync" +-- status the frontend polls. +CREATE TABLE sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), + started_at TEXT NOT NULL, + finished_at TEXT, + activities_fetched INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK(status IN ('running','success','error')), + error_message TEXT +); diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 2526979..f066bf9 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -2,10 +2,7 @@ package store import ( "context" - "database/sql" - "io/fs" "path/filepath" - "sort" "strings" "testing" ) @@ -23,7 +20,7 @@ func openTestDB(t *testing.T) *DB { func f(v float64) *float64 { return &v } -func TestMigrateIsIdempotent(t *testing.T) { +func TestOpenIsIdempotent(t *testing.T) { path := filepath.Join(t.TempDir(), "geniusrun_test.db") db1, err := Open(path) if err != nil { @@ -33,7 +30,7 @@ func TestMigrateIsIdempotent(t *testing.T) { db2, err := Open(path) if err != nil { - t.Fatalf("second Open (re-applying migrations): %v", err) + t.Fatalf("second Open (schema already applied): %v", err) } db2.Close() } @@ -242,22 +239,10 @@ func TestReplaceLapsIsIdempotent(t *testing.T) { } } -func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) { +func TestSchema_PerUserUniqueConstraints(t *testing.T) { db := openTestDB(t) ctx := context.Background() - // A fresh DB has no legacy singleton rows, so every user_id column - // should already be backfilled to nothing (fresh install, no rows at - // all yet in these tables besides the migration-seeded workout_kinds -- - // which do have NULL user_id until a real user is provisioned). - var nullableCount int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil { - t.Fatalf("count workout_kinds: %v", err) - } - if nullableCount != 8 { - t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount) - } - // 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 { t.Fatalf("insert users: %v", err) @@ -278,14 +263,12 @@ func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) { } } -func TestForeignKeyEnforcementPostMigration(t *testing.T) { +func TestForeignKeyEnforcement(t *testing.T) { db := openTestDB(t) ctx := context.Background() - // Verify that FK enforcement is correctly active after migrations complete. - // This tests that the PRAGMA foreign_keys toggle in db.migrate() (for - // migrations 0023/0024/0025) is properly scoped and re-enabled after each - // table rebuild. + // Verify FK enforcement is genuinely active: a valid reference succeeds, + // a bogus one is rejected, not silently accepted. userID, err := db.ProvisionUser(ctx, "test-sub", "Test") if err != nil { @@ -302,7 +285,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) { t.Fatalf("UpsertActivity: %v", err) } - // Get the ID of one of the 8 migration-seeded workout_kinds. + // Get the ID of one of the 8 workout_kinds ProvisionUser seeded. var seedKindID int64 if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil { t.Fatalf("query seeded workout_kind: %v", err) @@ -341,233 +324,6 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) { } } -// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific -// property none of the other migration tests cover: every other test opens -// a fresh DB via store.Open, which runs migrations 0001-0026 in one -// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's -// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any -// real pre-existing rows to carry across. A real single-tenant deployment -// upgrading to this schema version has months of synced activities, laps, -// activity_samples, and kind_assignments rows referencing real activities/ -// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile, -// real Garmin history, a review queue with manual overrides already -// recorded). This test manually applies migrations up through 0022, inserts -// rows simulating that pre-existing install, then applies 0023/0024/0025/0026 -// and verifies every FK-referencing row still resolves correctly -- and that -// FK enforcement is genuinely back on afterward -- rather than just checking -// that migrations apply to an empty DB without erroring. This is the -// regression guard for a real bug: migration 0026 (activities table rebuild) -// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`, -// which SQLite documents as a no-op once a transaction is open, so -// `DROP TABLE activities` silently cascade-deleted every laps/ -// activity_samples/kind_assignments row for every activity via -// `ON DELETE CASCADE` -- with no error at all. It was masked because every -// other test runs migrations back-to-back on an empty database with no -// pre-existing child rows. -func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db") - - // Deliberately not store.Open: that always applies every migration in - // one uninterrupted pass with no way to stop partway through. The sqlite - // driver itself is already registered via db.go's blank import. - sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - defer sqlDB.Close() - - if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( - filename TEXT PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - )`); err != nil { - t.Fatalf("create schema_migrations table: %v", err) - } - - rebuildMigrations := map[string]bool{ - "0023_profile_user_scoped.sql": true, - "0024_workout_kinds_user_scoped.sql": true, - "0025_sync_state_user_scoped.sql": true, - "0026_activities_unique_constraint.sql": true, - } - - // Mirrors db.go's migrate() method: FK toggle in autocommit mode around - // the transaction, only for the four table-rebuild migrations. - applyMigration := func(name string) { - t.Helper() - content, err := migrationsFS.ReadFile("migrations/" + name) - if err != nil { - t.Fatalf("read migration %s: %v", name, err) - } - - needsFKToggle := rebuildMigrations[name] - if needsFKToggle { - if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { - t.Fatalf("disable foreign keys before migration %s: %v", name, err) - } - } - - tx, err := sqlDB.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin migration tx for %s: %v", name, err) - } - if _, err := tx.ExecContext(ctx, string(content)); err != nil { - tx.Rollback() - t.Fatalf("apply migration %s: %v", name, err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { - tx.Rollback() - t.Fatalf("record migration %s: %v", name, err) - } - if err := tx.Commit(); err != nil { - t.Fatalf("commit migration %s: %v", name, err) - } - - if needsFKToggle { - if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { - t.Fatalf("enable foreign keys after migration %s: %v", name, err) - } - } - } - - entries, err := fs.Glob(migrationsFS, "migrations/*.sql") - if err != nil { - t.Fatalf("glob migrations: %v", err) - } - sort.Strings(entries) - - // Apply every migration up to (but not including) the three rebuilds. - for _, entry := range entries { - name := entry[len("migrations/"):] - if rebuildMigrations[name] { - continue - } - applyMigration(name) - } - - // Simulate a real pre-existing single-tenant install at this point in - // schema history: a workout kind, a synced activity, and a - // kind_assignment referencing both by foreign key. - res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`) - if err != nil { - t.Fatalf("insert pre-existing workout_kinds row: %v", err) - } - kindID, err := res.LastInsertId() - if err != nil { - t.Fatalf("workout_kinds LastInsertId: %v", err) - } - - res, err = sqlDB.ExecContext(ctx, ` - INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) - VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`) - if err != nil { - t.Fatalf("insert pre-existing activities row: %v", err) - } - activityID, err := res.LastInsertId() - if err != nil { - t.Fatalf("activities LastInsertId: %v", err) - } - - res, err = sqlDB.ExecContext(ctx, ` - INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) - VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID) - if err != nil { - t.Fatalf("insert pre-existing kind_assignments row: %v", err) - } - assignmentID, err := res.LastInsertId() - if err != nil { - t.Fatalf("kind_assignments LastInsertId: %v", err) - } - - res, err = sqlDB.ExecContext(ctx, ` - INSERT INTO laps (activity_id, lap_index, raw_json) - VALUES (?, 0, '{}')`, activityID) - if err != nil { - t.Fatalf("insert pre-existing laps row: %v", err) - } - lapID, err := res.LastInsertId() - if err != nil { - t.Fatalf("laps LastInsertId: %v", err) - } - - res, err = sqlDB.ExecContext(ctx, ` - INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate) - VALUES (?, 60, 1735711260000, 150)`, activityID) - if err != nil { - t.Fatalf("insert pre-existing activity_samples row: %v", err) - } - sampleID, err := res.LastInsertId() - if err != nil { - t.Fatalf("activity_samples LastInsertId: %v", err) - } - - // Now apply the four rebuild migrations that drop/recreate profile, - // workout_kinds, sync_state, and (0026) activities itself. - for _, name := range []string{ - "0023_profile_user_scoped.sql", - "0024_workout_kinds_user_scoped.sql", - "0025_sync_state_user_scoped.sql", - "0026_activities_unique_constraint.sql", - } { - applyMigration(name) - } - - // The pre-existing kind_assignment row must still resolve to the same - // workout_kind, by the same name, across the drop/recreate/rename. - var resolvedName string - if err := sqlDB.QueryRowContext(ctx, ` - SELECT wk.name FROM kind_assignments ka - JOIN workout_kinds wk ON wk.id = ka.workout_kind_id - WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil { - t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err) - } - if resolvedName != "Pre-Existing Custom Kind" { - t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName) - } - - // The pre-existing laps row must still exist and still reference the - // same activity -- this is the exact regression guard for migration - // 0026's DROP TABLE activities silently cascade-deleting laps via - // ON DELETE CASCADE when FK enforcement wasn't actually disabled. - var lapActivityID int64 - if err := sqlDB.QueryRowContext(ctx, ` - SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil { - t.Fatalf("query pre-existing laps row after rebuild: %v", err) - } - if lapActivityID != activityID { - t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID) - } - - // Same guard for activity_samples. - var sampleActivityID int64 - if err := sqlDB.QueryRowContext(ctx, ` - SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil { - t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err) - } - if sampleActivityID != activityID { - t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID) - } - - // FK enforcement must be genuinely active again post-migration: a bogus - // workout_kind_id, activity_id (laps), and activity_id (activity_samples) - // must all be rejected, not silently accepted. - if _, err := sqlDB.ExecContext(ctx, ` - INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) - VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil { - t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded") - } - if _, err := sqlDB.ExecContext(ctx, ` - INSERT INTO laps (activity_id, lap_index, raw_json) - VALUES (999999, 0, '{}')`); err == nil { - t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded") - } - if _, err := sqlDB.ExecContext(ctx, ` - INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate) - VALUES (999999, 60, 1735711260000, 150)`); err == nil { - t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded") - } -} - func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) { db := openTestDB(t) ctx := context.Background() diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go index 601c0ef..1e4933b 100644 --- a/backend/internal/store/users.go +++ b/backend/internal/store/users.go @@ -17,20 +17,8 @@ type User struct { CreatedAt string } -// CreateUser inserts a bare users row. Most callers want ProvisionUser -// instead, which also seeds the profile/taxonomy/sync-state a fresh account -// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which -// attaches an *existing* profile/taxonomy rather than seeding new ones. -func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) { - res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) - if err != nil { - return 0, fmt.Errorf("create user %q: %w", oidcSub, err) - } - return res.LastInsertId() -} - // GetUserBySub looks up a user by their OIDC subject -- the only lookup key -// the session-resolution middleware (Task 11) ever uses. +// 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). @@ -45,7 +33,7 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err } // ListUsers returns every provisioned user, for the background incremental -// sync loop (Task 13) to iterate. +// sync loop to iterate. func (db *DB) ListUsers(ctx context.Context) ([]User, error) { rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`) if err != nil { @@ -64,9 +52,9 @@ func (db *DB) ListUsers(ctx context.Context) ([]User, error) { return users, rows.Err() } -// neverMatchRule is the same placeholder every fresh install's rule-engine -// kinds start with (migration 0004) -- every activity lands in needs_review -// until the user tunes real rules. +// neverMatchRule is the placeholder every fresh install's rule-engine kinds +// start with -- every activity lands in needs_review until the user tunes +// real rules. const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}` // defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..e7b4b71 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,264 @@ +# geniusrun database schema + +Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit. The source of truth is `backend/internal/store/schema.sql`; regenerate this file after changing it. + +## Tables + +- [`users`](#users) +- [`profile`](#profile) +- [`workout_kinds`](#workout_kinds) +- [`workout_type_paces`](#workout_type_paces) +- [`activities`](#activities) +- [`laps`](#laps) +- [`activity_samples`](#activity_samples) +- [`kind_assignments`](#kind_assignments) +- [`sync_state`](#sync_state) +- [`sync_runs`](#sync_runs) + +## `users` + +```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')) +); +``` + +## `profile` + +```sql +CREATE TABLE profile ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'Default', + garmin_email TEXT NOT NULL DEFAULT '', + garmin_password TEXT NOT NULL DEFAULT '', + rolling_window_days INTEGER NOT NULL DEFAULT 90, + -- BackfillHorizonDays bounds how far back "Sync now" reaches when + -- walking backward from today; read fresh on every sync (not cached at + -- server startup), so changing it here takes effect on the next click. + backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, + max_heart_rate REAL, + resting_heart_rate REAL, + hr_zone1_min_pct REAL NOT NULL DEFAULT 50, + hr_zone1_max_pct REAL NOT NULL DEFAULT 60, + hr_zone2_min_pct REAL NOT NULL DEFAULT 60, + hr_zone2_max_pct REAL NOT NULL DEFAULT 70, + hr_zone3_min_pct REAL NOT NULL DEFAULT 70, + hr_zone3_max_pct REAL NOT NULL DEFAULT 80, + hr_zone4_min_pct REAL NOT NULL DEFAULT 80, + hr_zone4_max_pct REAL NOT NULL DEFAULT 90, + hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone5_max_pct REAL NOT NULL DEFAULT 100, + -- Applies uniformly to every fixed-duration workout type's phase + -- detection (Easy, Long, Tempo, Threshold 30'/60', MAS Test). Interval + -- workouts detect phases from lap data directly and don't use these. + warmup_minutes REAL NOT NULL DEFAULT 10, + cooldown_minutes REAL NOT NULL DEFAULT 5, + -- Review Queue pace-chart artifact filter: a stretch of samples slower + -- than min_representative_pace_sec_per_km is dropped unless it persists + -- for at least min_representative_time_seconds, in which case it's a + -- real stop/walk break rather than GPS/motion noise at recording start. + min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720, + min_representative_time_seconds REAL NOT NULL DEFAULT 3, + -- Chart colors: pace_color/heart_rate_color are each chart's "main + -- line" color; warmup/effort/recovery/cooldown_color are the "effort + -- kind" colors the frontend derives fills from (see + -- ExpectedVsActualChart). + pace_color TEXT NOT NULL DEFAULT '#3b82f6', + heart_rate_color TEXT NOT NULL DEFAULT '#ef4444', + warmup_color TEXT NOT NULL DEFAULT '#c2410c', + effort_color TEXT NOT NULL DEFAULT '#7c3aed', + recovery_color TEXT NOT NULL DEFAULT '#15803d', + cooldown_color TEXT NOT NULL DEFAULT '#fb923c', + main_line_tint_pct REAL NOT NULL DEFAULT 20, + background_darken_pct REAL NOT NULL DEFAULT 35, + target_brighten_pct REAL NOT NULL DEFAULT 20, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id) +); +``` + +## `workout_kinds` + +```sql +CREATE TABLE workout_kinds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, name) +); +``` + +## `workout_type_paces` + +```sql +CREATE TABLE workout_type_paces ( + workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), + pace_min_sec_per_km REAL, + pace_max_sec_per_km REAL, + hr_min_pct_hrr REAL, + hr_max_pct_hrr REAL +); +``` + +## `activities` + +```sql +CREATE TABLE activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + garmin_activity_id INTEGER NOT NULL, + -- Derived at sync time from Garmin's own eventType.typeKey=="race" -- + -- a hard fact, not a rule the user tunes (see internal/classify's + -- is_race metric and handleReclassifyAll's Race special-case). + event_type_key TEXT NOT NULL DEFAULT '', + -- Set when the activity was recorded from a structured Garmin workout; + -- used to resolve each lap's expected pace/HR band (see + -- internal/sync/mapping.go's alignWorkoutTargets). + workout_id INTEGER, + start_time_utc TEXT NOT NULL, + duration_seconds REAL NOT NULL, + distance_meters REAL NOT NULL, + avg_hr REAL, + max_hr REAL, + avg_speed_mps REAL, + elevation_gain_m REAL, + aerobic_training_effect REAL, + anaerobic_training_effect REAL, + vo2max_value REAL, + raw_json TEXT NOT NULL, + details_fetched_at TEXT, + details_raw_json TEXT, + splits_fetched_at TEXT, + -- Genuine raw get_workout_by_id() response, the source used to compute + -- alignWorkoutTargets. Null when the activity has no workout_id. + workout_raw_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, garmin_activity_id) +); +``` + +Indexes: + +```sql +CREATE INDEX idx_activities_start_time ON activities(start_time_utc); +``` + +## `laps` + +```sql +CREATE TABLE laps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + lap_index INTEGER NOT NULL, + avg_speed_mps REAL, + intensity_type TEXT NOT NULL DEFAULT '', + hr_drift_bpm_per_min REAL, + hr_recovery_bpm_per_min REAL, + -- Expected band for this lap, resolved from the activity's structured + -- Garmin workout when its steps line up 1:1 with the recorded laps. + -- Null when there's no structured workout or the counts don't match. + target_pace_low_mps REAL, + target_pace_high_mps REAL, + target_hr_low_bpm REAL, + target_hr_high_bpm REAL, + raw_json TEXT NOT NULL, + UNIQUE(activity_id, lap_index) +); +``` + +## `activity_samples` + +```sql +CREATE TABLE activity_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + elapsed_seconds REAL NOT NULL, + timestamp_ms INTEGER NOT NULL, + heart_rate REAL, + speed_mps REAL, + distance_m REAL, + elevation_m REAL +); +``` + +Indexes: + +```sql +CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds); +``` + +## `kind_assignments` + +```sql +CREATE TABLE kind_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + workout_kind_id INTEGER REFERENCES workout_kinds(id), + assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')), + status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')), + confidence REAL, + candidate_kinds_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +Indexes: + +```sql +CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id); +CREATE INDEX idx_kind_assignments_status ON kind_assignments(status); +``` + +## `sync_state` + +```sql +CREATE TABLE sync_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + earliest_synced_date TEXT, + backfill_complete INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id) +); +``` + +## `sync_runs` + +```sql +CREATE TABLE sync_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), + started_at TEXT NOT NULL, + finished_at TEXT, + activities_fetched INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK(status IN ('running','success','error')), + error_message TEXT +); +``` + +## Views + +### `current_kind_assignment` + +```sql +CREATE VIEW current_kind_assignment AS +SELECT a.* FROM kind_assignments a +JOIN ( + SELECT activity_id, MAX(id) AS max_id + FROM kind_assignments GROUP BY activity_id +) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id; +``` +