2026-07-25 19:24:19 +02:00
|
|
|
-- 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,
|
2026-07-26 10:17:21 +02:00
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
name TEXT NOT NULL DEFAULT 'Default',
|
|
|
|
|
garmin_email TEXT NOT NULL DEFAULT '',
|
|
|
|
|
garmin_password TEXT NOT NULL DEFAULT '',
|
2026-07-26 12:21:42 +02:00
|
|
|
-- Set once, the first time this user successfully authenticates with
|
|
|
|
|
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
|
|
|
|
-- the login gate uses this (not the in-memory auth status, which
|
|
|
|
|
-- resets on restart) to decide whether a returning user must
|
|
|
|
|
-- reconnect before entering the app.
|
|
|
|
|
garmin_connected_at TEXT,
|
2026-07-25 19:24:19 +02:00
|
|
|
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,
|
2026-07-26 10:17:21 +02:00
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
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 (
|
2026-07-26 10:17:21 +02:00
|
|
|
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
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,
|
2026-07-26 10:17:21 +02:00
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
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,
|
2026-07-26 10:17:21 +02:00
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
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,
|
2026-07-26 10:17:21 +02:00
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
2026-07-25 19:24:19 +02:00
|
|
|
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
|
|
|
|
|
);
|