Supabase Postgres is the source of truth. User-owned tables are protected by Row Level Security and keyed directly or indirectly to auth.uid().
Created automatically by public.handle_new_user() when a Supabase Auth user signs up.
id UUID PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE
strava_athlete_id BIGINT UNIQUE
strava_access_token TEXT
strava_refresh_token TEXT
token_expires_at TIMESTAMPTZ
units TEXT NOT NULL DEFAULT 'imperial' CHECK (units IN ('metric', 'imperial'))
accent_color TEXT NOT NULL DEFAULT 'blue' CHECK (accent_color IN ('blue', 'green', 'orange', 'purple'))
hr_zones JSONB
hr_zone_method TEXT NOT NULL DEFAULT 'strava' CHECK (hr_zone_method IN ('custom', 'strava', 'max_hr'))
max_heart_rate INTEGER NOT NULL DEFAULT 190 CHECK (max_heart_rate BETWEEN 100 AND 240)
pace_zones JSONB
long_run_distance_threshold NUMERIC NOT NULL DEFAULT 16
display_name TEXT
onboarding_completed_at TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT now()Current implementation stores Strava tokens on profiles; Vault is not used in this repo.
Single activity feed for Strava cardio, manual runs, and strength workouts.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
strava_activity_id BIGINT UNIQUE
type TEXT NOT NULL CHECK (type IN ('run', 'ride', 'workout', 'manual_run'))
day_type_id UUID REFERENCES day_types(id)
start_time TIMESTAMPTZ NOT NULL
duration INT
source TEXT NOT NULL CHECK (source IN ('strava', 'manual'))
distance NUMERIC
avg_heartrate NUMERIC
max_heartrate NUMERIC
suffer_score INT
calories INT
elevation_gain NUMERIC
avg_pace NUMERIC
tags TEXT[]
notes TEXT
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- Strava detail fields added by later migrations
name TEXT
summary_polyline TEXT
splits JSONB
best_efforts JSONB
avg_cadence NUMERIC
avg_watts NUMERIC
elapsed_time INT
max_speed NUMERIC
average_temp NUMERICGranular strength-session data. Workouts are saved through save_workout_session() and edited through update_workout_session().
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE
exercise_id UUID NOT NULL REFERENCES exercises(id)
set_number INT NOT NULL
reps INT NOT NULL
weight NUMERIC NOT NULL
rpe NUMERIC CHECK (rpe >= 1 AND rpe <= 10)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()Later hardening adds non-validated constraints for positive set number/reps and non-negative weight.
Global exercise taxonomy seeded by npm run seed:exercises.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
name TEXT NOT NULL UNIQUE
category TEXT NOT NULL CHECK (category IN ('push', 'pull', 'legs', 'core', 'other'))
primary_muscles TEXT[] NOT NULL DEFAULT '{}'
secondary_muscles TEXT[] NOT NULL DEFAULT '{}'
muscle_tags TEXT[] NOT NULL DEFAULT '{}'
movement_pattern TEXT NOT NULL CHECK (movement_pattern IN (
'horizontal_push', 'horizontal_pull', 'vertical_push', 'vertical_pull',
'quad_dominant', 'hip_hinge', 'elbow_flexion', 'elbow_extension',
'carry', 'core', 'other'
))
equipment TEXT NOT NULL DEFAULT 'bodyweight'
is_custom BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()Global day-type library. Built-ins are Push, Pull, Legs, Upper, Full Body, Easy, Long, Intervals, and Rest.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
name TEXT NOT NULL
category TEXT NOT NULL CHECK (category IN ('strength', 'run'))
muscle_focus TEXT[]Repeating plan. A row can hold one workout slot, one cardio slot, or both.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
day_of_week INT NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6)
day_type_id UUID REFERENCES day_types(id)
cardio_day_type_id UUID REFERENCES day_types(id)
active BOOLEAN NOT NULL DEFAULT true
UNIQUE (user_id, day_of_week)day_of_week uses ISO weekday order: 0 = Monday ... 6 = Sunday. Week ranges in the UI are displayed Sunday-Saturday, so date mapping must account for both conventions.
Per-date changes from the repeating schedule.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
date DATE NOT NULL
slot TEXT NOT NULL CHECK (slot IN ('workout', 'cardio'))
day_type_id UUID REFERENCES day_types(id) ON DELETE SET NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
UNIQUE (user_id, date, slot)day_type_id = NULL means the slot is skipped for that date.
Historical adherence snapshots. Current/future weeks are regenerated when the repeating schedule or an override changes; older weeks remain stable.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
week_start DATE NOT NULL
date DATE NOT NULL
day_of_week INT NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6)
slot TEXT NOT NULL CHECK (slot IN ('workout', 'cardio'))
planned_day_type_id UUID REFERENCES day_types(id) ON DELETE SET NULL
effective_day_type_id UUID REFERENCES day_types(id) ON DELETE SET NULL
is_overridden BOOLEAN NOT NULL DEFAULT false
is_skipped BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
UNIQUE (user_id, date, slot)activity_details(id, activity_id, data)
daily_checkins(id, user_id, date, body_weight, notes, created_at)
weekly_summaries(id, user_id, week_start, data, created_at)
pending_strava_links(id, user_id, strava_activity_id, strava_data, candidate_ids, created_at)
antagonist_pairs(pattern_a, pattern_b)activity_details and weekly_summaries exist for flexible detail/summary data, but the current UI primarily reads typed columns on activities.
notification_preferences(
user_id,
enabled,
today_plan_enabled,
today_plan_time,
pending_strava_enabled,
plan_nudge_enabled,
plan_nudge_time,
weekly_review_enabled,
weekly_review_day,
weekly_review_time,
timezone
)
push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, last_seen_at)
notification_events(user_id, kind, dedupe_key, title, body, url, sent_at)notification_events deduplicates once-per-day and once-per-week scheduled notifications, plus event-driven pending Strava link notifications.
Validates authentication, start time, non-negative duration, at least one set, positive reps/set numbers, non-negative weight, and RPE in 1..10. Inserts one activities row with type = 'workout', then inserts all session_sets in one database function call.
Validates ownership and workout type, validates the replacement sets, deletes existing sets for the workout, and inserts the replacement set list.
User-owned tables are scoped to auth.uid():
- Direct ownership:
profiles,activities,weekly_schedule,daily_checkins,weekly_summaries,schedule_overrides,planned_slots,pending_strava_links - Indirect ownership through
activities:activity_details,session_sets - Global read-mostly tables:
exercises,antagonist_pairs,day_types
Authenticated users and service_role receive explicit grants for app tables; RLS still enforces ownership for authenticated clients.
e1rm = weight * (1 + reps / 30)
Computed in TypeScript with computeE1RM(). One-rep sets return the actual weight.
Daily training load combines run and strength signals:
- Run TL: derived from run duration and available intensity signal, capped at 200 per day. Strava
suffer_scoreis not used as the source of truth, so free and subscriber runs are scored consistently. - Strength TL:
SUM(reps * weight * rpe) / 1000, capped at 200. - Daily TL:
runTL + strengthTL.
ATL = ATL_previous * (1 - 1/7) + dailyTL * (1/7)
CTL = CTL_previous * (1 - 1/42) + dailyTL * (1/42)
TSB = CTL - ATL
The Stats load tab displays the selected calendar range (current week, month, year, or all time) after computing ATL/CTL from the user's prior load history.
Linear regression over the selected body-weight points in stored kilograms:
> +0.2 kg/week: gaining< -0.2 kg/week: losing- otherwise: maintaining
Coverage uses session_sets -> exercises.primary_muscles. Secondary muscles are stored and used in search/filtering, but coverage counts are currently based on primary muscles.
Strength balance is separate from heatmap coverage. It combines movement patterns with primary/secondary muscle assignments and scores each axis against a target range rather than a strict 50/50 split.
deriveAdherence() greedily assigns activities to effective planned slots by type match and temporal proximity. A matched activity on the planned date is completed; a match on another date in the same week is swapped; past unmatched slots are missed; future unmatched slots are pending; skipped overrides are skipped.
Base antagonist movement pairs:
('horizontal_push', 'horizontal_pull')
('vertical_push', 'vertical_pull')
('quad_dominant', 'hip_hinge')
('elbow_flexion', 'elbow_extension')The balance engine also uses muscle assignments to catch isolation work that does not fit one of those movement patterns, such as hamstring curls, lateral raises, rear-delt isolation, calf work, and hip isolation.
Exercise taxonomy is seeded from the Wger open dataset plus local mapping logic in scripts/seed-exercises.ts.