51 Commits

Author SHA1 Message Date
059ba8f778 fix: remove service_healthy condition unsupported by podman 4.3.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:01:08 -05:00
4e6930c207 fix: reorder nav menu to place Dashboard after Home
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:57:43 -05:00
cea1b4e80e feat: add Caddy reverse proxy in front of app
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Caddy listens on port 80 and proxies to the app on 8000 internally.
App port is no longer exposed to the host directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:49:01 -05:00
bae0bc9dee feat: add missing import service and templates
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
These files were untracked and missing from prior commits, causing
ModuleNotFoundError on fresh deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:46:37 -05:00
df8d5c65fb feat: enhance dashboard with PRs, adherence, activity, progression chart, and muscle heatmap
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 13s
Add 3 new stat cards (Last Workout, Personal Records, Adherence Rate),
recent activity table, progression timeline chart, and muscle group
recency heatmap to the dashboard. Remove Total Volume card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:44:21 -05:00
c5a7728818 Merge origin/master: integrate auto-populate suggestions and set renumbering
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:43:14 -05:00
ebecfd0b58 feat: add CSV export of workout history to dashboard
Add date range picker and download button to the dashboard page,
backed by GET /dashboard/export endpoint that returns a StreamingResponse
CSV file. Completes Phase 4 (final V2 improvement).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:22:50 -05:00
0389aef56e Merge branch 'feature/rep-ladder-progression' 2026-03-13 13:57:04 -05:00
52e48f8ed4 feat: replace wk1/wk4 targets with 6→8→10→12 rep ladder progression
Simplifies the progression model to a universal rep ladder: every exercise
follows 6→8→10→12 reps at current weight, then +5 lbs and reset to 6.
Replaces per-user wk1/wk4 rep and weight targets with a single
starting_weight field.

- Add Alembic migration to drop wk1_reps/wk4_reps/wk1_weight/wk4_weight,
  add starting_weight (migrated from wk1_weight)
- Run Alembic migrations on app startup instead of create_all, with
  auto-detection and stamping for legacy databases
- Include alembic/ and alembic.ini in Docker image
- Rewrite progression_service.get_suggestion() with ladder logic:
  climb, hold, weight_increase, hold_at_top, deload
- Replace wk1/wk4 grid in exercise cards with rep ladder progress bar
- Add color-coded progression badges by type
- Change weight log input from text to number with pre-filled suggestion
- Normalize weight input in routes (0→BW, bare number→N lbs)
- Remove schedule page (route, template, nav link, tests)
- Simplify user_programs.yaml from 4 fields to 1 per exercise
- Update all tests for new schema and progression logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:57:02 -05:00
69b3357800 updating roadmap so phase 2 is complete 2026-03-13 13:07:37 -05:00
4b117c6fa7 feat: add smart "Workout Now" recommendation to workout picker
Auto-recommends the next workout in the 4-day cycle based on the
user's last completed session (one with logged sets). Redesigns
the /workouts page with highlighted recommendation card and
renames the back button to "Change Workout".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:05:39 -05:00
931e452205 docs: mark roadmap item 1 (remove auth) as complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:52:48 -05:00
5b26f36f5d fix: resolve stash conflict and update roadmap to v2
Resolved merge conflict in exercise_card.html (kept suggested
reps/weight pre-population logic) and updated roadmap.md with
the new v2 improvement plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:50:34 -05:00
758034b25a Merge branch 'feature/remove-auth' into master 2026-03-13 12:40:58 -05:00
576d3bbb68 feat: replace admin auth with cookie-based profile picker
Remove all authentication (login, sessions, bcrypt, itsdangerous) since
the app runs on a private homelab LAN. Replace with a profile picker
landing page and cookie-based profile selection (1-year expiry).

- Add Alembic migration to drop password_hash/is_admin columns
- Delete auth service, auth routes, login template, and auth tests
- Rewrite app/utils/auth.py with NoProfileSelectedError and
  require_active_profile dependency
- Add profile creation flow (GET/POST /profiles/create)
- Rewrite home page as profile picker with card layout
- Update all route files to use profile dependency instead of admin auth
- Remove bcrypt and itsdangerous from requirements
- Remove admin_username/admin_password from config
- Update all tests for new profile-based access model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:40:54 -05:00
91b3d24147 Merge pull request 'fix: renumber sets after delete and use last-logged values for prefill' (#2) from feature/auto-populate-suggestions into master
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
Reviewed-on: #2
2026-02-24 21:56:32 +00:00
2208f0492b fix: renumber sets after delete and use last-logged values for prefill
Two bugs fixed:
- Deleting a set left gaps in set numbering (1, 3, 3). Now renumbers
  remaining sets sequentially after deletion.
- Logging set 1 caused the prefill to recalculate via the progression
  engine, shifting suggested reps mid-session. Now prefills from the
  last logged set's actual values; progression suggestion is only used
  for the first set of a session.
2026-02-24 15:55:27 -06:00
60acdbefdb Merge pull request 'feature/auto-populate-suggestions' (#1) from feature/auto-populate-suggestions into master
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
Reviewed-on: #1
2026-02-24 21:49:05 +00:00
7b535bef6e fix(tests): align auth tests with NotAuthenticatedError and 302 redirect
The auth dependency raises NotAuthenticatedError (not HTTPException),
and the exception handler returns a 302 redirect. Updated the unit test
to expect NotAuthenticatedError, and all route auth tests to accept 302
alongside 401/303.
2026-02-24 15:47:36 -06:00
272563060c feat: auto-populate suggested reps and weight in log form
Pre-fill the reps and weight inputs with progression engine suggestions
so users can log sets without manually retyping values each time.
Suggestions flow through the template chain on initial page load and
on all HTMX partial responses (log, edit, delete).
2026-02-24 15:46:04 -06:00
3dc0171639 updating dockerfiles for deployment vs local dev
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-02-24 15:37:21 -06:00
312b14e57b updating dockerfiles for deployment vs local dev 2026-02-24 15:37:16 -06:00
d8b52cf907 fix(ci): use REGISTRY_TOKEN secret for container registry auth
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
The automatic GITHUB_TOKEN lacks package write permissions. Switch to
a manually configured PAT stored as REGISTRY_TOKEN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:47:09 -06:00
b18146e96c fix(ci): use act-latest container for Docker and Node.js support
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 7s
Replace manual Node.js install with catthehacker/ubuntu:act-latest
container which includes Node.js, Docker, and all build dependencies
needed for Gitea Actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:42:30 -06:00
ee45513f30 fix(ci): install Node.js 20 for actions/checkout@v4 compatibility
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9m38s
The default apt nodejs package is too old to support static class blocks
required by checkout@v4. Use NodeSource to install Node.js 20.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:32:39 -06:00
d90c9faf23 Merge branch 'feat/package-build-workflow'
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 35s
2026-02-24 14:31:28 -06:00
093f7aa55e ci: add Gitea Actions workflow for Docker build and registry push
Adds a CI pipeline that builds the Docker image and pushes it to the
Gitea container registry on every push to master. Includes .dockerignore
to keep the build context lean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:30:19 -06:00
77d1bc4a25 chore: add uv.lock for reproducible dependency resolution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:20:39 -06:00
89d70fcae7 Merge branch 'docs/cleanup-implementation-files' 2026-02-24 14:19:54 -06:00
53e62f694f docs: replace implementation plans with architecture and API reference docs
Remove phase implementation plans, design notes, and source spreadsheet
that are no longer needed. Add architecture.md, API_REFERENCE.md, and
database_schema.md for ongoing development and debugging reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:19:49 -06:00
fd979aa2da Merge branch 'fix/log-form-exercise-undefined' 2026-02-24 14:00:37 -06:00
215ce90404 fix: resolve template errors, orphaned sessions, and auth redirects
- Fix exercise_id undefined error in log_form.html by using scalar
  exercise_id instead of exercise.id object reference
- Clean up orphaned WorkoutSession records when all logs are deleted
- Filter empty sessions from dashboard stats (sessions, volume, streak)
- Replace broken HTTPException auth redirect with custom exception
  handler that properly returns 302 to /login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:00:34 -06:00
134542b66f feat: add Phase 5 Progression & Analytics — smart suggestions, dashboard, schedule
Add auto-progression engine (ProgressionService) with rep increase, weight
increase, deload, and felt-easy acceleration rules. Add AnalyticsService for
user stats, exercise progress charts, and volume-by-day data. New dashboard
and schedule routes with Chart.js visualizations. Progression badges shown
inline on workout day view. Navigation updated with Dashboard and Schedule links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:26:23 -06:00
e35b78ae87 feat: add Phase 4 Logging & Tracking — inline set logging, history views
Add workout logging so users can track sets, reps, weight, and a
"felt easy?" toggle inline from the workout day view via HTMX.
Sessions auto-create on first log. History page shows past sessions
with detailed per-exercise breakdowns.

New services: WorkoutSessionService, LogService
New routes: POST /log, /log/{id}/edit, /log/{id}/delete, GET /history, /history/{id}
New templates: log_form, log_entry, session_card, log_history, session_detail
Modified: exercise_card (inline logging), nav (History link), workouts route (session context)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:12:23 -06:00
23754ea239 feat: add Phase 3 Workout UI — auth, profiles, workout viewer, exercise browser
Build the core user-facing experience with admin login (bcrypt + signed
session cookies), profile switcher, workout day viewer with warmups and
exercise cards, and HTMX-powered exercise browser with search/filter.

- AuthService with bcrypt password verification and itsdangerous session tokens
- Auth dependency redirects to /login (303) for unauthenticated requests
- NavContextMiddleware injects admin/profiles/active_profile into all templates
- Profile management (list, switch, edit) with cookie-based active profile
- Workout day viewer shows warmups + exercises + per-user programming targets
- Exercise browser with HTMX filter dropdowns (no page reloads)
- Flash message partial for success/error feedback
- 12 new tests (66 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:14:52 -06:00
1f47103480 Merge branch 'feat/phase2-data-layer' 2026-02-24 10:15:10 -06:00
7c07bcee25 feat: wire database init and seeding into app startup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:14:32 -06:00
6f9e923846 feat: add SeedService for YAML-based database seeding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:09:05 -06:00
42f6667b23 feat: add ExerciseService with filtering and warmup support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:07:33 -06:00
afb2cdf308 feat: add UserService with CRUD operations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:04:55 -06:00
e6ee17d1ff feat: add Alembic migrations with initial 8-table schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:52:31 -06:00
3bf1e13adc feat: define all 8 SQLModel tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:35:13 -06:00
45b93a2988 feat: add database engine and session management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:32:26 -06:00
f9ff42c665 Merge branch 'feat/phase1-scaffold' 2026-02-24 09:16:03 -06:00
835ab197eb feat: add Dockerfile and docker-compose for containerized deployment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:15:01 -06:00
df7e86f2ed feat: add base template with Pico CSS dark theme and home page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:13:28 -06:00
b3b34222c8 feat: add FastAPI app factory and health check endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:12:31 -06:00
fb4b948681 feat: add structlog logging configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:05:14 -06:00
a211f9b6bd feat: add typed configuration loader with env support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:04:41 -06:00
2a9891ce69 feat: create project directory structure and env template
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:55:04 -06:00
4afc7c35a6 feat: add project metadata and pinned dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:54:46 -06:00
118 changed files with 7322 additions and 298 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.example
data/
docs/
tests/
alembic/__pycache__
*.md
*.lock
.claude/
.gitea/
__pycache__
*.pyc
docker-compose.yaml
docker-compose.dev.yaml

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# SneakySwole Environment Configuration
# Copy this file to .env and fill in real values.
# App settings
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=info
# Database
DATABASE_URL=sqlite:///data/sneakyswole.db

9
.env.production Normal file
View File

@@ -0,0 +1,9 @@
# SneakySwole Production Environment
# Copy to .env on your production server and fill in real values.
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=warning
DATABASE_URL=sqlite:///data/sneakyswole.db

View File

@@ -0,0 +1,46 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.sneakygeek.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: git.sneakygeek.net/sneakygeek/sneakyswole
tags: |
type=raw,value=latest
type=sha,prefix=sha-,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache
cache-to: type=registry,ref=git.sneakygeek.net/sneakygeek/sneakyswole:buildcache,mode=max

3
Caddyfile Normal file
View File

@@ -0,0 +1,3 @@
:80 {
reverse_proxy app:8000
}

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# -- Build stage --
FROM python:3.12-slim AS base
# Prevent Python from writing .pyc files and enable unbuffered output
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies first (layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY config/ ./config/
COPY alembic/ ./alembic/
COPY alembic.ini .
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

149
alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///data/sneakyswole.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

57
alembic/env.py Normal file
View File

@@ -0,0 +1,57 @@
"""Alembic environment configuration.
Imports all SQLModel models so autogenerate detects schema changes.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
# Import all models so SQLModel.metadata knows about them
from app.models import ( # noqa: F401
User, Exercise, Warmup, WorkoutDay,
UserExerciseProgram, WorkoutSession, WorkoutLog, ProgressLog,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (SQL script generation)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode (direct DB connection)."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,155 @@
"""initial schema - 8 tables
Revision ID: 1855836abf6c
Revises:
Create Date: 2026-02-24 09:35:47.297957
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1855836abf6c'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('exercises',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('muscle_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('workout_day', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sets', sa.Integer(), nullable=False),
sa.Column('tempo', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_exercises_name'), 'exercises', ['name'], unique=False)
op.create_index(op.f('ix_exercises_workout_day'), 'exercises', ['workout_day'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('height', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('goals', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('warmups',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('form_cues', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_warmups_name'), 'warmups', ['name'], unique=False)
op.create_table('workout_days',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('day_number', sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('day_number')
)
op.create_index(op.f('ix_workout_days_name'), 'workout_days', ['name'], unique=True)
op.create_table('progress_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('suggested_reps', sa.Integer(), nullable=True),
sa.Column('suggested_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('actual_reps', sa.Integer(), nullable=True),
sa.Column('actual_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('progression_applied', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_progress_log_user_id'), 'progress_log', ['user_id'], unique=False)
op.create_table('user_exercise_programs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('wk1_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_reps', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk1_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('wk4_weight', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_exercise_programs_exercise_id'), 'user_exercise_programs', ['exercise_id'], unique=False)
op.create_index(op.f('ix_user_exercise_programs_user_id'), 'user_exercise_programs', ['user_id'], unique=False)
op.create_table('workout_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('workout_day_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['workout_day_id'], ['workout_days.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_sessions_user_id'), 'workout_sessions', ['user_id'], unique=False)
op.create_table('workout_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=False),
sa.Column('exercise_id', sa.Integer(), nullable=False),
sa.Column('set_number', sa.Integer(), nullable=False),
sa.Column('reps_completed', sa.Integer(), nullable=False),
sa.Column('weight_used', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('felt_easy', sa.Boolean(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['exercise_id'], ['exercises.id'], ),
sa.ForeignKeyConstraint(['session_id'], ['workout_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_workout_logs_session_id'), 'workout_logs', ['session_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_workout_logs_session_id'), table_name='workout_logs')
op.drop_table('workout_logs')
op.drop_index(op.f('ix_workout_sessions_user_id'), table_name='workout_sessions')
op.drop_table('workout_sessions')
op.drop_index(op.f('ix_user_exercise_programs_user_id'), table_name='user_exercise_programs')
op.drop_index(op.f('ix_user_exercise_programs_exercise_id'), table_name='user_exercise_programs')
op.drop_table('user_exercise_programs')
op.drop_index(op.f('ix_progress_log_user_id'), table_name='progress_log')
op.drop_table('progress_log')
op.drop_index(op.f('ix_workout_days_name'), table_name='workout_days')
op.drop_table('workout_days')
op.drop_index(op.f('ix_warmups_name'), table_name='warmups')
op.drop_table('warmups')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_exercises_workout_day'), table_name='exercises')
op.drop_index(op.f('ix_exercises_name'), table_name='exercises')
op.drop_table('exercises')
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""remove auth columns from users
Revision ID: a1b2c3d4e5f6
Revises: 1855836abf6c
Create Date: 2026-03-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '1855836abf6c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Drop password_hash and is_admin columns, delete admin users."""
# Delete admin users before dropping the column
op.execute("DELETE FROM users WHERE is_admin = 1")
# SQLite requires table recreation for column drops
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('password_hash')
batch_op.drop_column('is_admin')
def downgrade() -> None:
"""Re-add password_hash and is_admin columns."""
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('password_hash', sa.String(), nullable=False, server_default=''))
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=False, server_default='0'))

View File

@@ -0,0 +1,45 @@
"""simplify user_exercise_programs to starting_weight
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2026-03-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6g7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('user_exercise_programs') as batch_op:
batch_op.add_column(sa.Column('starting_weight', sa.String(), nullable=False, server_default=''))
op.execute("UPDATE user_exercise_programs SET starting_weight = wk1_weight")
with op.batch_alter_table('user_exercise_programs') as batch_op:
batch_op.drop_column('wk1_reps')
batch_op.drop_column('wk4_reps')
batch_op.drop_column('wk1_weight')
batch_op.drop_column('wk4_weight')
def downgrade() -> None:
with op.batch_alter_table('user_exercise_programs') as batch_op:
batch_op.add_column(sa.Column('wk1_reps', sa.String(), server_default=''))
batch_op.add_column(sa.Column('wk4_reps', sa.String(), server_default=''))
batch_op.add_column(sa.Column('wk1_weight', sa.String(), server_default=''))
batch_op.add_column(sa.Column('wk4_weight', sa.String(), server_default=''))
op.execute("UPDATE user_exercise_programs SET wk1_weight = starting_weight")
with op.batch_alter_table('user_exercise_programs') as batch_op:
batch_op.drop_column('starting_weight')

0
app/__init__.py Normal file
View File

50
app/config.py Normal file
View File

@@ -0,0 +1,50 @@
"""Application configuration loader.
Reads environment variables (with .env file support) and provides
a typed, validated Settings object used throughout the application.
"""
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
# Load .env file from project root
_env_path = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(_env_path)
class Settings(BaseSettings):
"""Typed application settings loaded from environment variables.
Attributes:
app_env: Runtime environment — 'development' or 'production'.
app_host: Host address to bind the server to.
app_port: Port number for the server.
app_log_level: Minimum log level for structlog output.
database_url: SQLite connection string.
"""
app_env: str = "development"
app_host: str = "0.0.0.0"
app_port: int = 8000
app_log_level: str = "info"
database_url: str = "sqlite:///data/sneakyswole.db"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return a cached Settings instance (singleton pattern).
Returns:
The application Settings object.
"""
return Settings()

60
app/database.py Normal file
View File

@@ -0,0 +1,60 @@
"""Database engine and session management.
Provides a SQLModel engine factory and a session dependency
for use with FastAPI's dependency injection.
"""
from pathlib import Path
from typing import Generator
import structlog
from sqlmodel import Session, create_engine
logger = structlog.get_logger(__name__)
def get_engine(database_url: str):
"""Create a SQLAlchemy engine for the given database URL.
For SQLite, ensures the parent directory exists and sets
appropriate connection arguments.
Args:
database_url: SQLAlchemy-compatible connection string.
Returns:
A configured SQLAlchemy Engine.
"""
connect_args = {}
if database_url.startswith("sqlite"):
# Ensure the data directory exists
db_path = database_url.replace("sqlite:///", "")
if db_path and db_path != ":memory:":
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False}
engine = create_engine(
database_url,
connect_args=connect_args,
echo=False,
)
logger.info("database_engine_created", url=database_url)
return engine
def get_db_session(engine) -> Generator[Session, None, None]:
"""Yield a SQLModel session for dependency injection.
Usage in FastAPI routes:
@router.get("/example")
def example(session: Session = Depends(get_db_session)):
...
Args:
engine: The SQLAlchemy engine to bind the session to.
Yields:
A SQLModel Session that auto-closes after use.
"""
with Session(engine) as session:
yield session

48
app/logging_config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Structured logging configuration using structlog.
Call setup_logging() once at application startup to configure
structlog with human-readable (dev) or JSON (prod) output.
"""
import logging
import sys
import structlog
def setup_logging(log_level: str = "info") -> None:
"""Configure structlog for the application.
In development, output is colorized and human-readable.
In production, output is JSON for structured log aggregation.
Args:
log_level: Minimum log level as a string (e.g., 'info', 'debug').
"""
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Configure standard library logging (captured by structlog)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=numeric_level,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)

176
app/main.py Normal file
View File

@@ -0,0 +1,176 @@
"""FastAPI application factory for SneakySwole.
Creates and configures the FastAPI app with routes, templates,
static files, and structured logging.
"""
from pathlib import Path
import structlog
from alembic import command as alembic_command
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response
from fastapi.responses import RedirectResponse
from app.models.user import User
from app.config import get_settings
from app.database import get_engine, get_db_session
from app.logging_config import setup_logging
from app.routes.exercises import router as exercises_router
from app.routes.history import router as history_router
from app.routes.health import router as health_router
from app.routes.pages import router as pages_router
from app.routes.profiles import router as profiles_router
from app.routes.logging import router as logging_router
from app.routes.workouts import router as workouts_router
from app.routes.dashboard import router as dashboard_router
from app.services.seed_service import SeedService
from app.services.user_service import UserService
from app.utils.auth import NoProfileSelectedError
logger = structlog.get_logger(__name__)
# Template and static file directories
_BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static"
class NavContextMiddleware(BaseHTTPMiddleware):
"""Injects profiles and active_profile into request.state for templates."""
async def dispatch(self, request: StarletteRequest, call_next: RequestResponseEndpoint) -> Response:
request.state.profiles = []
request.state.active_profile = None
if hasattr(request.app.state, "engine"):
try:
with Session(request.app.state.engine) as session:
user_service = UserService(session)
request.state.profiles = user_service.list_users()
profile_id = request.cookies.get("active_profile_id")
if profile_id and profile_id.isdigit():
request.state.active_profile = user_service.get_user_by_id(int(profile_id))
except Exception:
pass
return await call_next(request)
def _run_migrations(database_url: str) -> None:
"""Run Alembic migrations to bring the DB schema up to date.
On a fresh DB this creates all tables via the initial migration.
On an existing DB created by create_all (no alembic_version table),
stamps the last known pre-migration revision, then upgrades.
"""
from sqlalchemy import create_engine as sa_create_engine, inspect
project_root = Path(__file__).resolve().parent.parent
alembic_cfg = AlembicConfig(str(project_root / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(project_root / "alembic"))
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
engine = sa_create_engine(database_url)
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
if existing_tables and "alembic_version" not in existing_tables:
# DB was created by create_all — stamp it at the last revision
# that matches the current schema so migrations run from there.
# Check which schema state we're in by looking at columns.
columns = {c["name"] for c in inspector.get_columns("user_exercise_programs")}
if "wk1_reps" in columns:
# Old schema: stamp at remove-auth migration (before rep ladder)
alembic_command.stamp(alembic_cfg, "a1b2c3d4e5f6")
logger.info("alembic_stamped", revision="a1b2c3d4e5f6", reason="legacy_db_old_schema")
elif "starting_weight" in columns:
# New schema already: stamp at head
alembic_command.stamp(alembic_cfg, "head")
logger.info("alembic_stamped", revision="head", reason="legacy_db_new_schema")
else:
# Unknown state — stamp at initial and let migrations sort it out
alembic_command.stamp(alembic_cfg, "1855836abf6c")
logger.info("alembic_stamped", revision="1855836abf6c", reason="legacy_db_unknown")
elif not existing_tables:
# Fresh DB — create alembic_version table so upgrade starts from scratch
logger.info("fresh_database_detected")
engine.dispose()
alembic_command.upgrade(alembic_cfg, "head")
logger.info("migrations_applied")
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
Returns:
A fully configured FastAPI application instance.
"""
settings = get_settings()
setup_logging(log_level=settings.app_log_level)
app = FastAPI(
title="SneakySwole",
description="Open-source workout tracking and programming",
version="0.1.0",
)
# Redirect to home when no profile is selected
@app.exception_handler(NoProfileSelectedError)
async def _no_profile_handler(request, exc):
return RedirectResponse(url="/", status_code=302)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Jinja2 templates (available to routes via request.state or dependency)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates
# Nav context middleware — injects profiles/active_profile into request.state
app.add_middleware(NavContextMiddleware)
# Register route modules
app.include_router(exercises_router)
app.include_router(health_router)
app.include_router(history_router)
app.include_router(logging_router)
app.include_router(pages_router)
app.include_router(profiles_router)
app.include_router(workouts_router)
app.include_router(dashboard_router)
# Database setup — run Alembic migrations instead of create_all
engine = get_engine(settings.database_url)
_run_migrations(settings.database_url)
app.state.engine = engine
# DB session dependency for routes
def _get_session():
yield from get_db_session(engine)
app.dependency_overrides[get_db_session] = _get_session
# Seed database on startup
@app.on_event("startup")
async def on_startup():
with Session(engine) as session:
seed_service = SeedService(session)
seed_service.seed_all()
logger.info("app_started", environment=settings.app_env)
return app
# Uvicorn entry point
app = create_app()

24
app/models/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""SQLModel model definitions for SneakySwole.
All 8 tables are defined here and re-exported for convenient imports.
"""
from app.models.user import User
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.models.progress_log import ProgressLog
__all__ = [
"User",
"Exercise",
"Warmup",
"WorkoutDay",
"UserExerciseProgram",
"WorkoutSession",
"WorkoutLog",
"ProgressLog",
]

35
app/models/exercise.py Normal file
View File

@@ -0,0 +1,35 @@
"""Exercise model for the exercise library catalog.
Each exercise belongs to a workout day and includes form cues.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Exercise(SQLModel, table=True):
"""An exercise in the workout library.
Attributes:
id: Primary key, auto-incremented.
name: Exercise name (e.g., "DB Chest Press (Floor)").
muscle_group: Target muscle group (e.g., "Chest", "Shoulders").
workout_day: Which day this exercise belongs to (Push/Pull/Lower/Full Body).
sets: Default number of sets.
tempo: Tempo notation (e.g., "3-1-2" = 3s eccentric, 1s pause, 2s concentric).
form_cues: Detailed form instructions.
created_at: Timestamp when the record was created.
"""
__tablename__ = "exercises"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
muscle_group: str = Field(default="")
workout_day: str = Field(index=True)
sets: int = Field(default=3)
tempo: str = Field(default="")
form_cues: str = Field(default="")
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,39 @@
"""ProgressLog model for tracking progression suggestions and outcomes.
Records what the progression engine suggested vs what the user actually did.
"""
import datetime as dt
from typing import Optional
from sqlmodel import Field, SQLModel
class ProgressLog(SQLModel, table=True):
"""A progression tracking entry for a specific exercise.
Attributes:
id: Primary key, auto-incremented.
user_id: FK to users table.
exercise_id: FK to exercises table.
date: The date this progression entry applies to.
suggested_reps: What the engine recommended.
suggested_weight: What the engine recommended.
actual_reps: What the user actually did.
actual_weight: What the user actually used.
progression_applied: Type of progression (e.g., "reps_increase", "weight_increase", "deload").
created_at: Timestamp when the record was created.
"""
__tablename__ = "progress_log"
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", index=True)
exercise_id: int = Field(foreign_key="exercises.id")
date: dt.date = Field(default_factory=dt.date.today)
suggested_reps: Optional[int] = Field(default=None)
suggested_weight: Optional[str] = Field(default=None)
actual_reps: Optional[int] = Field(default=None)
actual_weight: Optional[str] = Field(default=None)
progression_applied: Optional[str] = Field(default=None)
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)

35
app/models/user.py Normal file
View File

@@ -0,0 +1,35 @@
"""User model for profile management.
Stores user profiles with physical stats and goals.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
"""A user profile in the system.
Attributes:
id: Primary key, auto-incremented.
username: Unique identifier.
display_name: Human-readable name shown in the UI.
height: User's height as a string (e.g., "6'0\"").
weight: User's weight as a string (e.g., "260 lbs").
goals: Free-text training goals.
created_at: Timestamp when the record was created.
updated_at: Timestamp of the last update.
"""
__tablename__ = "users"
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
display_name: str = Field(default="")
height: Optional[str] = Field(default=None)
weight: Optional[str] = Field(default=None)
goals: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,31 @@
"""UserExerciseProgram model for per-user exercise programming.
Links a user to an exercise with a starting weight for the rep ladder.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class UserExerciseProgram(SQLModel, table=True):
"""Per-user programming for a specific exercise.
Attributes:
id: Primary key, auto-incremented.
user_id: FK to users table.
exercise_id: FK to exercises table.
starting_weight: Starting weight (e.g., "30 lbs", "BW").
created_at: Timestamp when the record was created.
updated_at: Timestamp of the last update.
"""
__tablename__ = "user_exercise_programs"
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", index=True)
exercise_id: int = Field(foreign_key="exercises.id", index=True)
starting_weight: str = Field(default="")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

33
app/models/warmup.py Normal file
View File

@@ -0,0 +1,33 @@
"""Warmup model for the standardized warmup routine.
Warmups are displayed before every workout in a fixed order.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Warmup(SQLModel, table=True):
"""A warmup exercise in the standardized routine.
Attributes:
id: Primary key, auto-incremented.
name: Warmup name (e.g., "Cat / Cow").
type: Category (e.g., "Thoracic Mob", "Hip Mobility").
reps: Rep scheme as a string (e.g., "8 reps", "8 each side").
form_cues: Detailed form instructions.
sort_order: Display order in the warmup sequence.
created_at: Timestamp when the record was created.
"""
__tablename__ = "warmups"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
type: str = Field(default="")
reps: str = Field(default="")
form_cues: str = Field(default="")
sort_order: int = Field(default=0)
created_at: datetime = Field(default_factory=datetime.utcnow)

26
app/models/workout_day.py Normal file
View File

@@ -0,0 +1,26 @@
"""WorkoutDay model for the 4-day training split.
Defines the named workout days and their order.
"""
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutDay(SQLModel, table=True):
"""A named workout day in the training program.
Attributes:
id: Primary key, auto-incremented.
name: Day name (Push, Pull, Lower, Full Body).
day_number: Order in the weekly rotation (1-4).
description: Brief description of the day's focus.
"""
__tablename__ = "workout_days"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
day_number: int = Field(unique=True)
description: str = Field(default="")

37
app/models/workout_log.py Normal file
View File

@@ -0,0 +1,37 @@
"""WorkoutLog model for per-exercise set logging.
Each log entry records one set of one exercise within a session.
"""
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutLog(SQLModel, table=True):
"""A single set log for an exercise within a workout session.
Attributes:
id: Primary key, auto-incremented.
session_id: FK to workout_sessions table.
exercise_id: FK to exercises table.
set_number: Which set this is (1, 2, 3...).
reps_completed: Actual reps performed.
weight_used: Weight used as string (e.g., "30 lbs", "BW").
felt_easy: Whether the user felt the set was easy (progression signal).
notes: Optional notes about this specific set.
created_at: Timestamp when the record was created.
"""
__tablename__ = "workout_logs"
id: Optional[int] = Field(default=None, primary_key=True)
session_id: int = Field(foreign_key="workout_sessions.id", index=True)
exercise_id: int = Field(foreign_key="exercises.id")
set_number: int = Field(default=1)
reps_completed: int = Field(default=0)
weight_used: str = Field(default="")
felt_easy: bool = Field(default=False)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,31 @@
"""WorkoutSession model for tracking completed workout sessions.
Each session ties a user to a workout day on a specific date.
"""
import datetime as dt
from typing import Optional
from sqlmodel import Field, SQLModel
class WorkoutSession(SQLModel, table=True):
"""A completed workout session.
Attributes:
id: Primary key, auto-incremented.
user_id: FK to users table.
workout_day_id: FK to workout_days table.
date: The date the workout was performed.
notes: Optional free-text notes about the session.
created_at: Timestamp when the record was created.
"""
__tablename__ = "workout_sessions"
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", index=True)
workout_day_id: int = Field(foreign_key="workout_days.id")
date: dt.date = Field(default_factory=dt.date.today)
notes: Optional[str] = Field(default=None)
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)

0
app/routes/__init__.py Normal file
View File

174
app/routes/dashboard.py Normal file
View File

@@ -0,0 +1,174 @@
"""Progress dashboard routes.
Displays summary statistics, volume charts, per-exercise progress,
and CSV export of workout history.
"""
import csv
import io
import json
import os
import re
import tempfile
from datetime import date, timedelta
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.analytics_service import AnalyticsService
from app.services.exercise_service import ExerciseService
from app.services.export_service import ExportService
from app.services.import_service import ImportService
from app.services.progression_service import ProgressionService
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("", response_class=HTMLResponse)
async def dashboard(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Render the progress dashboard for the active profile."""
analytics = AnalyticsService(session)
stats = analytics.get_user_stats(profile.id)
volume_data = analytics.get_volume_by_day(profile.id)
personal_records = analytics.get_personal_records(profile.id)
adherence = analytics.get_adherence_rate(profile.id)
progression_timeline = analytics.get_progression_timeline(profile.id)
muscle_recency = analytics.get_muscle_group_recency(profile.id)
recent_activity = analytics.get_recent_activity(profile.id)
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
today = date.today()
export_start = (today - timedelta(days=30)).isoformat()
export_end = today.isoformat()
templates = request.app.state.templates
return templates.TemplateResponse("pages/dashboard.html", {
"request": request,
"stats": stats,
"volume_data_json": json.dumps(volume_data),
"personal_records": personal_records,
"adherence": adherence,
"progression_timeline_json": json.dumps(progression_timeline),
"muscle_recency": muscle_recency,
"recent_activity": recent_activity,
"exercises": exercises,
"active_profile": profile,
"export_start_date": export_start,
"export_end_date": export_end,
})
@router.get("/export")
async def export_csv(
request: Request,
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Export workout history as a CSV file download."""
today = date.today()
default_start = today - timedelta(days=30)
try:
parsed_start = date.fromisoformat(start_date) if start_date else default_start
except ValueError:
parsed_start = default_start
try:
parsed_end = date.fromisoformat(end_date) if end_date else today
except ValueError:
parsed_end = today
export_service = ExportService(session)
rows = export_service.get_export_rows(profile.id, parsed_start, parsed_end)
output = io.StringIO()
headers = ["date", "workout_type", "exercise", "set_number", "reps", "weight", "felt_easy"]
writer = csv.DictWriter(output, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
safe_name = re.sub(r"[^a-z0-9_]", "", profile.display_name.lower().replace(" ", "_"))
filename = f"sneakyswole_{safe_name}_{parsed_start.isoformat()}_to_{parsed_end.isoformat()}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import", response_class=HTMLResponse)
async def import_db(
request: Request,
db_file: UploadFile = File(...),
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Import workout history from an old SneakySwole database file."""
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".db")
try:
with os.fdopen(tmp_fd, "wb") as tmp:
content = await db_file.read()
logger.info("import_upload_received", filename=db_file.filename, size=len(content))
tmp.write(content)
import_service = ImportService(session)
result = import_service.import_from_db(tmp_path)
except Exception:
logger.exception("import_failed")
raise
finally:
os.unlink(tmp_path)
templates = request.app.state.templates
return templates.TemplateResponse("partials/import_results.html", {
"request": request,
"result": result,
})
@router.get("/exercise/{exercise_id}", response_class=HTMLResponse)
async def exercise_progress(
exercise_id: int,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Render per-exercise progress page with charts and suggestions."""
exercise_service = ExerciseService(session)
exercise = exercise_service.get_exercise_by_id(exercise_id)
analytics = AnalyticsService(session)
progress_data = analytics.get_exercise_progress(
profile.id, exercise_id,
)
progression = ProgressionService(session)
suggestion = progression.get_suggestion(
profile.id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_progress.html", {
"request": request,
"exercise": exercise,
"progress_data_json": json.dumps(progress_data),
"suggestion": suggestion,
"active_profile": profile,
})

63
app/routes/exercises.py Normal file
View File

@@ -0,0 +1,63 @@
"""Exercise browser routes with HTMX search/filter support.
All filtering is done via HTMX partial responses -- no JSON APIs.
"""
import structlog
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/exercises", tags=["exercises"])
@router.get("", response_class=HTMLResponse)
async def exercise_browser(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Render the exercise browser page with all exercises."""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises()
workout_days = exercise_service.list_workout_days()
# Collect unique muscle groups for the filter dropdown
muscle_groups = sorted(set(ex.muscle_group for ex in exercises))
templates = request.app.state.templates
return templates.TemplateResponse("pages/exercise_browser.html", {
"request": request,
"exercises": exercises,
"workout_days": workout_days,
"muscle_groups": muscle_groups,
})
@router.get("/search", response_class=HTMLResponse)
async def exercise_search(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
workout_day: str = Query(default="", alias="workout_day"),
muscle_group: str = Query(default="", alias="muscle_group"),
):
"""Return filtered exercise list as an HTMX partial."""
exercise_service = ExerciseService(session)
exercises = exercise_service.list_exercises(
workout_day=workout_day or None,
muscle_group=muscle_group or None,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/exercise_list.html", {
"request": request,
"exercises": exercises,
})

23
app/routes/health.py Normal file
View File

@@ -0,0 +1,23 @@
"""Health check route for monitoring and readiness probes.
Provides a simple GET /health endpoint that returns application
status, name, and version.
"""
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check() -> dict:
"""Return application health status.
Returns:
JSON with app name, version, and status.
"""
return {
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok",
}

81
app/routes/history.py Normal file
View File

@@ -0,0 +1,81 @@
"""Log history routes for viewing past workout sessions.
Displays a list of past sessions and detailed logs per session.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import require_active_profile
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/history", tags=["history"])
@router.get("", response_class=HTMLResponse)
async def log_history(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Display log history for the active profile."""
ws_service = WorkoutSessionService(session)
sessions_list = ws_service.list_sessions(user_id=profile.id)
# Resolve workout day names for display
exercise_service = ExerciseService(session)
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
templates = request.app.state.templates
return templates.TemplateResponse("pages/log_history.html", {
"request": request,
"sessions": sessions_list,
"days_by_id": days_by_id,
"active_profile": profile,
})
@router.get("/{session_id}", response_class=HTMLResponse)
async def session_detail(
session_id: int,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Display detailed logs for a specific workout session."""
ws_service = WorkoutSessionService(session)
ws = ws_service.get_session_by_id(session_id)
log_service = LogService(session)
logs = log_service.list_logs_for_session(session_id)
# Group logs by exercise
exercise_service = ExerciseService(session)
exercises_by_id = {}
logs_by_exercise = {}
for log in logs:
if log.exercise_id not in exercises_by_id:
exercises_by_id[log.exercise_id] = (
exercise_service.get_exercise_by_id(log.exercise_id)
)
logs_by_exercise.setdefault(log.exercise_id, []).append(log)
# Resolve workout day name
days_by_id = {d.id: d for d in exercise_service.list_workout_days()}
templates = request.app.state.templates
return templates.TemplateResponse("pages/session_detail.html", {
"request": request,
"workout_session": ws,
"logs_by_exercise": logs_by_exercise,
"exercises_by_id": exercises_by_id,
"days_by_id": days_by_id,
})

206
app/routes/logging.py Normal file
View File

@@ -0,0 +1,206 @@
"""Workout logging routes for inline set tracking.
Handles creating, editing, and deleting individual set logs.
All responses are HTMX partials that update in place.
"""
from datetime import date
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/log", tags=["logging"])
def _normalize_weight(raw: str) -> str:
"""Convert numeric weight input to display format.
'0' or '' -> 'BW', bare number -> '{n} lbs', already formatted -> pass through.
"""
raw = raw.strip()
if not raw or raw == "0":
return "BW"
try:
num = float(raw)
if num == int(num):
return f"{int(num)} lbs"
return f"{num} lbs"
except ValueError:
return raw
def _get_prefill_values(
logs: list,
session: Session,
profile_id: int,
exercise_id: int,
) -> tuple:
"""Get pre-fill values for the next set form.
If sets have already been logged this session, use the last logged
set's values (users typically repeat the same reps/weight across sets).
Otherwise, use the progression engine's suggestion.
Returns:
(suggested_reps, suggested_weight) tuple.
"""
if logs:
last = logs[-1]
return last.reps_completed, last.weight_used
progression = ProgressionService(session)
suggestion = progression.get_suggestion(profile_id, exercise_id)
return suggestion.get("suggested_reps"), suggestion.get("suggested_weight")
@router.post("", response_class=HTMLResponse)
async def log_set(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Log a single set for an exercise.
Creates the workout session if it doesn't exist yet (auto-create).
Returns the updated log entries partial for this exercise.
"""
form = await request.form()
exercise_id = int(form.get("exercise_id", 0))
workout_day_id = int(form.get("workout_day_id", 0))
set_number = int(form.get("set_number", 1))
reps = int(form.get("reps", 0))
weight = _normalize_weight(form.get("weight", ""))
felt_easy = form.get("felt_easy") == "on"
# Get or create today's session
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=profile.id,
workout_day_id=workout_day_id,
session_date=date.today(),
)
# Create the log entry
log_service = LogService(session)
log_service.create_log(
session_id=ws.id,
exercise_id=exercise_id,
set_number=set_number,
reps_completed=reps,
weight_used=weight,
felt_easy=felt_easy,
)
# Return updated logs for this exercise
logs = log_service.list_logs_for_exercise(ws.id, exercise_id)
next_set = len(logs) + 1
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, profile.id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": exercise_id,
"workout_day_id": workout_day_id,
"next_set": next_set,
"session_id": ws.id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
@router.post("/{log_id}/edit", response_class=HTMLResponse)
async def edit_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Edit an existing log entry."""
form = await request.form()
log_service = LogService(session)
log_service.update_log(
log_id,
reps_completed=int(form.get("reps", 0)),
weight_used=_normalize_weight(form.get("weight", "")),
felt_easy=form.get("felt_easy") == "on",
notes=form.get("notes"),
)
log = log_service.get_log_by_id(log_id)
logs = log_service.list_logs_for_exercise(log.session_id, log.exercise_id)
next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, log.exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": log.exercise_id,
"workout_day_id": 0,
"next_set": next_set,
"session_id": log.session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
@router.post("/{log_id}/delete", response_class=HTMLResponse)
async def delete_log(
log_id: int,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Delete a log entry."""
log_service = LogService(session)
log = log_service.get_log_by_id(log_id)
if log:
exercise_id = log.exercise_id
session_id = log.session_id
log_service.delete_log(log_id)
logs = log_service.list_logs_for_exercise(session_id, exercise_id)
next_set = len(logs) + 1
active_profile_id = get_active_profile_id(request)
suggested_reps, suggested_weight = None, None
if active_profile_id:
suggested_reps, suggested_weight = _get_prefill_values(
logs, session, active_profile_id, exercise_id,
)
templates = request.app.state.templates
return templates.TemplateResponse("partials/log_entry.html", {
"request": request,
"logs": logs,
"exercise_id": exercise_id,
"workout_day_id": 0,
"next_set": next_set,
"session_id": session_id,
"suggested_reps": suggested_reps,
"suggested_weight": suggested_weight,
})
return HTMLResponse("")

29
app/routes/pages.py Normal file
View File

@@ -0,0 +1,29 @@
"""Page routes for serving full HTML pages.
Renders Jinja2 templates for user-facing pages.
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session
from app.database import get_db_session
from app.services.user_service import UserService
router = APIRouter(tags=["pages"])
@router.get("/")
async def home_page(
request: Request,
session: Session = Depends(get_db_session),
) -> HTMLResponse:
"""Render the home page with profile picker or create-profile link."""
user_service = UserService(session)
profiles = user_service.list_users()
templates = request.app.state.templates
return templates.TemplateResponse("pages/home.html", {
"request": request,
"profiles": profiles,
})

168
app/routes/profiles.py Normal file
View File

@@ -0,0 +1,168 @@
"""Profile management routes.
Users can view, create, edit profiles and switch the active profile.
"""
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("", response_class=HTMLResponse)
async def list_profiles(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""List all user profiles."""
user_service = UserService(session)
profiles = user_service.list_users()
active_profile_id = get_active_profile_id(request)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profiles.html", {
"request": request,
"profiles": profiles,
"active_profile_id": active_profile_id,
})
@router.post("/switch")
async def switch_profile(
request: Request,
session: Session = Depends(get_db_session),
):
"""Switch the active user profile.
Sets a cookie with the selected profile ID (1 year expiry).
"""
form = await request.form()
profile_id = form.get("profile_id", "")
user_service = UserService(session)
profile = user_service.get_user_by_id(int(profile_id)) if profile_id.isdigit() else None
response = RedirectResponse(url="/workouts", status_code=303)
if profile:
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=31536000, # 1 year
)
logger.info("profile_switched", profile_id=profile.id, name=profile.display_name)
else:
logger.warning("profile_switch_failed", profile_id=profile_id)
response = RedirectResponse(url="/", status_code=303)
return response
@router.get("/create", response_class=HTMLResponse)
async def create_profile_form(request: Request):
"""Render the create-profile form."""
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
})
@router.post("/create")
async def create_profile(
request: Request,
session: Session = Depends(get_db_session),
):
"""Create a new profile, set cookie, redirect to workouts."""
form = await request.form()
display_name = form.get("display_name", "").strip()
if not display_name:
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
"error": "Display name is required.",
})
user_service = UserService(session)
# Generate username from display name
username = display_name.lower().replace(" ", "_")
# Ensure uniqueness
existing = user_service.get_user_by_username(username)
if existing:
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_create.html", {
"request": request,
"error": "A profile with that name already exists.",
})
profile = user_service.create_user(
username=username,
display_name=display_name,
height=form.get("height", "").strip() or None,
weight=form.get("weight", "").strip() or None,
goals=form.get("goals", "").strip() or None,
)
response = RedirectResponse(url="/workouts", status_code=303)
response.set_cookie(
key="active_profile_id",
value=str(profile.id),
httponly=True,
samesite="lax",
max_age=31536000, # 1 year
)
logger.info("profile_created", profile_id=profile.id, name=profile.display_name)
return response
@router.get("/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_page(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
active_profile: User = Depends(require_active_profile),
):
"""Render the profile edit form."""
user_service = UserService(session)
profile = user_service.get_user_by_id(profile_id)
templates = request.app.state.templates
return templates.TemplateResponse("pages/profile_edit.html", {
"request": request,
"profile": profile,
})
@router.post("/{profile_id}/edit")
async def update_profile(
profile_id: int,
request: Request,
session: Session = Depends(get_db_session),
active_profile: User = Depends(require_active_profile),
):
"""Process profile edit form submission."""
form = await request.form()
user_service = UserService(session)
user_service.update_user(
profile_id,
display_name=form.get("display_name", ""),
height=form.get("height", ""),
weight=form.get("weight", ""),
goals=form.get("goals", ""),
)
return RedirectResponse(url="/profiles", status_code=303)

138
app/routes/workouts.py Normal file
View File

@@ -0,0 +1,138 @@
"""Workout day viewer routes.
Displays the warmup routine and main exercises for each workout day,
with the active profile's programming targets.
"""
from datetime import date
import structlog
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlmodel import Session, select
from app.database import get_db_session
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_day import WorkoutDay
from app.services.exercise_service import ExerciseService
from app.services.log_service import LogService
from app.services.progression_service import ProgressionService
from app.services.workout_session_service import WorkoutSessionService
from app.utils.auth import require_active_profile, get_active_profile_id
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/workouts", tags=["workouts"])
@router.get("", response_class=HTMLResponse)
async def workout_days_list(
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""List all workout days with a recommendation for what to do next."""
exercise_service = ExerciseService(session)
days = exercise_service.list_workout_days()
ws_service = WorkoutSessionService(session)
last_session = ws_service.get_last_completed_session(profile.id)
recommended_day_id = None
last_workout_name = None
last_workout_date = None
if last_session:
last_day = session.get(WorkoutDay, last_session.workout_day_id)
if last_day:
last_workout_name = last_day.name
last_workout_date = last_session.date
next_day_number = (last_day.day_number % 4) + 1
for d in days:
if d.day_number == next_day_number:
recommended_day_id = d.id
break
else:
for d in days:
if d.day_number == 1:
recommended_day_id = d.id
break
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_days.html", {
"request": request,
"days": days,
"recommended_day_id": recommended_day_id,
"last_workout_name": last_workout_name,
"last_workout_date": last_workout_date,
})
@router.get("/{day_name}", response_class=HTMLResponse)
async def workout_day_detail(
day_name: str,
request: Request,
session: Session = Depends(get_db_session),
profile: User = Depends(require_active_profile),
):
"""Display a full workout day -- warmups + exercises with form cues."""
exercise_service = ExerciseService(session)
# Normalize day name for DB lookup (e.g., "push" -> "Push", "full-body" -> "Full Body")
day_display = day_name.replace("-", " ").title()
warmups = exercise_service.list_warmups()
exercises = exercise_service.list_exercises(workout_day=day_display)
# Get active profile's programming
active_profile_id = profile.id
programs = {}
existing_logs = {}
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == active_profile_id
)
for prog in session.exec(statement).all():
programs[prog.exercise_id] = prog
# Look up the workout day ID for logging forms
days = exercise_service.list_workout_days()
workout_day_id = 0
for d in days:
if d.name == day_display:
workout_day_id = d.id
break
# Get progression suggestions for each exercise
suggestions = {}
progression = ProgressionService(session)
for exercise in exercises:
suggestions[exercise.id] = progression.get_suggestion(
active_profile_id, exercise.id,
)
# Load existing logs for today's session (if any)
if workout_day_id:
ws_service = WorkoutSessionService(session)
ws = ws_service.get_or_create_session(
user_id=active_profile_id,
workout_day_id=workout_day_id,
session_date=date.today(),
)
log_service = LogService(session)
all_logs = log_service.list_logs_for_session(ws.id)
for log in all_logs:
existing_logs.setdefault(log.exercise_id, []).append(log)
templates = request.app.state.templates
return templates.TemplateResponse("pages/workout_day.html", {
"request": request,
"day_name": day_display,
"warmups": warmups,
"exercises": exercises,
"programs": programs,
"active_profile": profile,
"existing_logs": existing_logs,
"suggestions": suggestions,
"workout_day_id": workout_day_id,
})

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,377 @@
"""Analytics service for progress dashboard and chart data.
Aggregates workout log data into stats, trends, and chart-ready formats.
"""
import re
from datetime import date, timedelta
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.progress_log import ProgressLog
from app.models.workout_day import WorkoutDay
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
def _weight_to_float(weight_str: str) -> float:
"""Convert weight string to float for volume calculations.
Args:
weight_str: Weight like '30 lbs' or 'BW'.
Returns:
Numeric weight, or 0.0 for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return 0.0
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else 0.0
class AnalyticsService:
"""Aggregates workout data for dashboards and charts.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_user_stats(self, user_id: int) -> dict:
"""Get summary statistics for a user.
Returns:
Dict with keys: total_sessions, total_volume, total_sets,
current_streak, last_workout_date.
"""
all_sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
# Only count sessions that still have log entries
sessions = []
total_volume = 0.0
total_sets = 0
for ws in all_sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
sessions.append(ws)
for log_entry in logs:
total_sets += 1
weight = _weight_to_float(log_entry.weight_used)
total_volume += log_entry.reps_completed * weight
total_sessions = len(sessions)
current_streak = 0
if sessions:
week_start = date.today() - timedelta(days=date.today().weekday())
for week_offset in range(52):
week_check = week_start - timedelta(weeks=week_offset)
week_end = week_check + timedelta(days=6)
has_session = any(
week_check <= ws.date <= week_end for ws in sessions
)
if has_session:
current_streak += 1
else:
break
last_workout = sessions[0].date if sessions else None
return {
"total_sessions": total_sessions,
"total_volume": round(total_volume),
"total_sets": total_sets,
"current_streak": current_streak,
"last_workout_date": last_workout,
}
def get_exercise_progress(
self, user_id: int, exercise_id: int,
) -> dict:
"""Get chart-ready progress data for a specific exercise.
Returns:
Dict with keys: dates, reps, weights, volumes.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.asc())
).all()
dates = []
reps = []
weights = []
volumes = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(
log_entry.reps_completed for log_entry in logs
) / len(logs)
weight = _weight_to_float(logs[0].weight_used)
session_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
dates.append(ws.date.isoformat())
reps.append(round(avg_reps, 1))
weights.append(weight)
volumes.append(round(session_volume))
return {
"dates": dates,
"reps": reps,
"weights": weights,
"volumes": volumes,
}
def get_volume_by_day(self, user_id: int) -> dict:
"""Get total volume broken down by workout day.
Returns:
Dict mapping workout day name to total volume.
"""
days = self._session.exec(select(WorkoutDay)).all()
day_map = {d.id: d.name for d in days}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
).all()
volume_by_day = {}
for ws in sessions:
day_name = day_map.get(ws.workout_day_id, "Unknown")
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
day_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
volume_by_day[day_name] = (
volume_by_day.get(day_name, 0) + round(day_volume)
)
return volume_by_day
def get_personal_records(self, user_id: int) -> list[dict]:
"""Get per-exercise max weight records for a user.
Returns:
List of dicts with exercise_name, weight, weight_display, date.
Sorted by weight descending. BW-only exercises excluded.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
).all()
# Map exercise_id -> {max_weight, weight_str, date, exercise_name}
records: dict[int, dict] = {}
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
for log_entry in logs:
weight = _weight_to_float(log_entry.weight_used)
if weight == 0.0:
continue
existing = records.get(log_entry.exercise_id)
if existing is None or weight > existing["weight"]:
records[log_entry.exercise_id] = {
"exercise_id": log_entry.exercise_id,
"weight": weight,
"weight_display": log_entry.weight_used,
"date": ws.date,
}
# Resolve exercise names
result = []
for exercise_id, rec in records.items():
exercise = self._session.get(Exercise, exercise_id)
if exercise:
rec["exercise_name"] = exercise.name
result.append(rec)
result.sort(key=lambda r: r["weight"], reverse=True)
return result
def get_adherence_rate(self, user_id: int, weeks: int = 8) -> dict:
"""Calculate workout adherence rate over the past N weeks.
Returns:
Dict with rate (0-100), completed, expected, weeks.
"""
cutoff = date.today() - timedelta(weeks=weeks)
sessions = self._session.exec(
select(WorkoutSession)
.where(
WorkoutSession.user_id == user_id,
WorkoutSession.date >= cutoff,
)
).all()
# Only count sessions with logs
completed = 0
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if logs:
completed += 1
expected = weeks * 4
rate = round((completed / expected) * 100) if expected > 0 else 0
return {
"rate": min(rate, 100),
"completed": completed,
"expected": expected,
"weeks": weeks,
}
def get_muscle_group_recency(self, user_id: int) -> list[dict]:
"""Get the most recent workout date for each muscle group.
Returns:
List of dicts with muscle_group, last_worked, days_ago.
Sorted by days_ago descending (most stale first).
"""
exercises = self._session.exec(select(Exercise)).all()
muscle_groups = {e.muscle_group for e in exercises if e.muscle_group}
# Map exercise_id -> muscle_group for fast lookup
ex_muscle = {e.id: e.muscle_group for e in exercises}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
recency: dict[str, date] = {}
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
for log_entry in logs:
mg = ex_muscle.get(log_entry.exercise_id, "")
if mg and (mg not in recency or ws.date > recency[mg]):
recency[mg] = ws.date
today = date.today()
result = []
for mg in sorted(muscle_groups):
last_worked = recency.get(mg)
days_ago = (today - last_worked).days if last_worked else None
result.append({
"muscle_group": mg,
"last_worked": last_worked,
"days_ago": days_ago,
})
# Sort: never-worked first, then most stale
result.sort(
key=lambda r: (r["days_ago"] is None, -(r["days_ago"] or 0)),
reverse=True,
)
return result
def get_recent_activity(self, user_id: int, limit: int = 5) -> list[dict]:
"""Get the last N workout sessions with summary data.
Returns:
List of dicts with date, workout_day_name, total_volume, total_sets.
"""
days = self._session.exec(select(WorkoutDay)).all()
day_map = {d.id: d.name for d in days}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
result = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
total_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
result.append({
"date": ws.date,
"workout_day_name": day_map.get(ws.workout_day_id, "Unknown"),
"total_volume": round(total_volume),
"total_sets": len(logs),
})
if len(result) >= limit:
break
return result
def get_progression_timeline(self, user_id: int) -> dict:
"""Get progression history for Chart.js multi-line chart.
Returns:
Dict with 'exercises' key mapping exercise names to
{dates, weights, events} lists.
"""
logs = self._session.exec(
select(ProgressLog)
.where(ProgressLog.user_id == user_id)
.order_by(ProgressLog.date.asc())
).all()
exercises: dict[int, list] = {}
for pl in logs:
exercises.setdefault(pl.exercise_id, []).append(pl)
result = {}
for exercise_id, entries in exercises.items():
exercise = self._session.get(Exercise, exercise_id)
if not exercise:
continue
name = exercise.name
result[name] = {
"dates": [e.date.isoformat() for e in entries],
"weights": [
_weight_to_float(e.actual_weight or e.suggested_weight or "0")
for e in entries
],
"events": [e.progression_applied or "" for e in entries],
}
return {"exercises": result}

View File

@@ -0,0 +1,88 @@
"""Service layer for exercise, warmup, and workout day data access.
All exercise-related database operations go through this service.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
class ExerciseService:
"""Handles read operations for exercises, warmups, and workout days.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def list_exercises(
self,
workout_day: Optional[str] = None,
muscle_group: Optional[str] = None,
) -> list[Exercise]:
"""List exercises with optional filtering.
Args:
workout_day: Filter by workout day name (e.g., "Push").
muscle_group: Filter by muscle group (e.g., "Chest").
Returns:
List of matching Exercise records.
"""
statement = select(Exercise)
if workout_day:
statement = statement.where(Exercise.workout_day == workout_day)
if muscle_group:
statement = statement.where(Exercise.muscle_group == muscle_group)
return list(self._session.exec(statement).all())
def get_exercise_by_id(self, exercise_id: int) -> Optional[Exercise]:
"""Retrieve an exercise by primary key.
Args:
exercise_id: The exercise ID.
Returns:
The Exercise record, or None if not found.
"""
return self._session.get(Exercise, exercise_id)
def get_exercise_by_name(self, name: str) -> Optional[Exercise]:
"""Retrieve an exercise by exact name match.
Args:
name: The exercise name to look up.
Returns:
The Exercise record, or None if not found.
"""
statement = select(Exercise).where(Exercise.name == name)
return self._session.exec(statement).first()
def list_warmups(self) -> list[Warmup]:
"""List all warmups in display order.
Returns:
List of Warmup records sorted by sort_order.
"""
statement = select(Warmup).order_by(Warmup.sort_order)
return list(self._session.exec(statement).all())
def list_workout_days(self) -> list[WorkoutDay]:
"""List all workout days in order.
Returns:
List of WorkoutDay records sorted by day_number.
"""
statement = select(WorkoutDay).order_by(WorkoutDay.day_number)
return list(self._session.exec(statement).all())

View File

@@ -0,0 +1,80 @@
"""Export service for generating CSV-ready workout history data.
Queries workout logs joined with sessions, days, and exercises
to produce flat rows suitable for CSV export.
"""
from datetime import date
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class ExportService:
"""Builds export-ready workout history rows.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_export_rows(
self, user_id: int, start_date: date, end_date: date,
) -> list[dict]:
"""Get flat workout log rows for CSV export.
Args:
user_id: The user whose data to export.
start_date: Inclusive start of date range.
end_date: Inclusive end of date range.
Returns:
List of dicts with keys: date, workout_type, exercise,
set_number, reps, weight, felt_easy.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(
WorkoutSession.user_id == user_id,
WorkoutSession.date >= start_date,
WorkoutSession.date <= end_date,
)
.order_by(WorkoutSession.date.asc())
).all()
if not sessions:
return []
# Pre-load exercises for name lookup
exercises = self._session.exec(select(Exercise)).all()
exercise_map = {e.id: e for e in exercises}
rows = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog)
.where(WorkoutLog.session_id == ws.id)
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
).all()
for log_entry in logs:
exercise = exercise_map.get(log_entry.exercise_id)
rows.append({
"date": ws.date.isoformat(),
"workout_type": exercise.workout_day if exercise else "Unknown",
"exercise": exercise.name if exercise else "Unknown",
"set_number": log_entry.set_number,
"reps": log_entry.reps_completed,
"weight": log_entry.weight_used,
"felt_easy": "Yes" if log_entry.felt_easy else "No",
})
return rows

View File

@@ -0,0 +1,281 @@
"""Import service for loading workout history from an old database.
Opens the old SQLite DB read-only, maps IDs by name, and imports
workout_sessions, workout_logs, and progress_log into the current DB.
"""
import sqlite3
from dataclasses import dataclass, field
from datetime import date, datetime
import structlog
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.progress_log import ProgressLog
from app.models.user import User
from app.models.workout_day import WorkoutDay
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
@dataclass
class ImportResult:
"""Tracks counts and warnings from an import operation."""
sessions_imported: int = 0
sessions_skipped: int = 0
logs_imported: int = 0
logs_skipped: int = 0
progress_imported: int = 0
progress_skipped: int = 0
warnings: list[str] = field(default_factory=list)
class ImportService:
"""Imports workout history from an old SneakySwole database.
Args:
session: An active SQLModel Session for the current DB.
"""
def __init__(self, session: Session) -> None:
self._session = session
def import_from_db(self, db_path: str) -> ImportResult:
"""Import workout data from an old database file.
Matches users, exercises, and workout days by name between
old and new databases. Skips duplicates for idempotency.
Args:
db_path: Path to the old SQLite database file.
Returns:
ImportResult with counts and any warnings.
"""
result = ImportResult()
old_db = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
old_db.row_factory = sqlite3.Row
try:
user_map = self._build_user_map(old_db, result)
exercise_map = self._build_exercise_map(old_db, result)
workout_day_map = self._build_workout_day_map(old_db, result)
session_map = self._import_sessions(
old_db, user_map, workout_day_map, result,
)
self._import_logs(old_db, session_map, exercise_map, result)
self._import_progress(old_db, user_map, exercise_map, result)
self._session.commit()
finally:
old_db.close()
logger.info(
"import_complete",
sessions=result.sessions_imported,
logs=result.logs_imported,
progress=result.progress_imported,
warnings=len(result.warnings),
)
return result
def _build_user_map(
self, old_db: sqlite3.Connection, result: ImportResult,
) -> dict[int, int]:
"""Map old user IDs to new user IDs by matching on username."""
new_users = self._session.exec(select(User)).all()
new_user_by_name = {u.username: u.id for u in new_users}
user_map: dict[int, int] = {}
for row in old_db.execute("SELECT id, username FROM users"):
new_id = new_user_by_name.get(row["username"])
if new_id is not None:
user_map[row["id"]] = new_id
else:
result.warnings.append(
f"User '{row['username']}' (old id={row['id']}) not found in new DB — skipping their data",
)
return user_map
def _build_exercise_map(
self, old_db: sqlite3.Connection, result: ImportResult,
) -> dict[int, int]:
"""Map old exercise IDs to new exercise IDs by matching on name."""
new_exercises = self._session.exec(select(Exercise)).all()
new_ex_by_name = {e.name: e.id for e in new_exercises}
exercise_map: dict[int, int] = {}
for row in old_db.execute("SELECT id, name FROM exercises"):
new_id = new_ex_by_name.get(row["name"])
if new_id is not None:
exercise_map[row["id"]] = new_id
else:
result.warnings.append(
f"Exercise '{row['name']}' (old id={row['id']}) not found in new DB — skipping its logs",
)
return exercise_map
def _build_workout_day_map(
self, old_db: sqlite3.Connection, result: ImportResult,
) -> dict[int, int]:
"""Map old workout_day IDs to new ones by matching on name."""
new_days = self._session.exec(select(WorkoutDay)).all()
new_day_by_name = {d.name: d.id for d in new_days}
day_map: dict[int, int] = {}
for row in old_db.execute("SELECT id, name FROM workout_days"):
new_id = new_day_by_name.get(row["name"])
if new_id is not None:
day_map[row["id"]] = new_id
else:
result.warnings.append(
f"Workout day '{row['name']}' (old id={row['id']}) not found in new DB",
)
return day_map
def _import_sessions(
self,
old_db: sqlite3.Connection,
user_map: dict[int, int],
workout_day_map: dict[int, int],
result: ImportResult,
) -> dict[int, int]:
"""Import workout_sessions, returning old_id -> new_id map."""
session_map: dict[int, int] = {}
rows = old_db.execute(
"SELECT id, user_id, workout_day_id, date, notes, created_at "
"FROM workout_sessions ORDER BY id",
).fetchall()
for row in rows:
new_user_id = user_map.get(row["user_id"])
new_day_id = workout_day_map.get(row["workout_day_id"])
if new_user_id is None or new_day_id is None:
result.sessions_skipped += 1
continue
# Check for duplicate by (user_id, workout_day_id, date)
existing = self._session.exec(
select(WorkoutSession).where(
WorkoutSession.user_id == new_user_id,
WorkoutSession.workout_day_id == new_day_id,
WorkoutSession.date == row["date"],
),
).first()
if existing:
session_map[row["id"]] = existing.id
result.sessions_skipped += 1
continue
new_session = WorkoutSession(
user_id=new_user_id,
workout_day_id=new_day_id,
date=date.fromisoformat(row["date"]),
notes=row["notes"],
created_at=datetime.fromisoformat(row["created_at"]),
)
self._session.add(new_session)
self._session.flush()
session_map[row["id"]] = new_session.id
result.sessions_imported += 1
return session_map
def _import_logs(
self,
old_db: sqlite3.Connection,
session_map: dict[int, int],
exercise_map: dict[int, int],
result: ImportResult,
) -> None:
"""Import workout_logs using mapped session and exercise IDs."""
rows = old_db.execute(
"SELECT session_id, exercise_id, set_number, reps_completed, "
"weight_used, felt_easy, notes, created_at "
"FROM workout_logs ORDER BY id",
).fetchall()
for row in rows:
new_session_id = session_map.get(row["session_id"])
new_exercise_id = exercise_map.get(row["exercise_id"])
if new_session_id is None or new_exercise_id is None:
result.logs_skipped += 1
continue
existing = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == new_session_id,
WorkoutLog.exercise_id == new_exercise_id,
WorkoutLog.set_number == row["set_number"],
),
).first()
if existing:
result.logs_skipped += 1
continue
self._session.add(WorkoutLog(
session_id=new_session_id,
exercise_id=new_exercise_id,
set_number=row["set_number"],
reps_completed=row["reps_completed"],
weight_used=row["weight_used"],
felt_easy=bool(row["felt_easy"]),
notes=row["notes"],
created_at=datetime.fromisoformat(row["created_at"]),
))
result.logs_imported += 1
def _import_progress(
self,
old_db: sqlite3.Connection,
user_map: dict[int, int],
exercise_map: dict[int, int],
result: ImportResult,
) -> None:
"""Import progress_log using mapped user and exercise IDs."""
rows = old_db.execute(
"SELECT user_id, exercise_id, date, suggested_reps, "
"suggested_weight, actual_reps, actual_weight, "
"progression_applied, created_at "
"FROM progress_log ORDER BY id",
).fetchall()
for row in rows:
new_user_id = user_map.get(row["user_id"])
new_exercise_id = exercise_map.get(row["exercise_id"])
if new_user_id is None or new_exercise_id is None:
result.progress_skipped += 1
continue
existing = self._session.exec(
select(ProgressLog).where(
ProgressLog.user_id == new_user_id,
ProgressLog.exercise_id == new_exercise_id,
ProgressLog.date == row["date"],
),
).first()
if existing:
result.progress_skipped += 1
continue
self._session.add(ProgressLog(
user_id=new_user_id,
exercise_id=new_exercise_id,
date=date.fromisoformat(row["date"]),
suggested_reps=row["suggested_reps"],
suggested_weight=row["suggested_weight"],
actual_reps=row["actual_reps"],
actual_weight=row["actual_weight"],
progression_applied=row["progression_applied"],
created_at=datetime.fromisoformat(row["created_at"]),
))
result.progress_imported += 1

227
app/services/log_service.py Normal file
View File

@@ -0,0 +1,227 @@
"""Service layer for workout log (set-level) data access.
Handles CRUD for individual set logs within workout sessions.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class LogService:
"""Handles CRUD operations for WorkoutLog records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def create_log(
self,
session_id: int,
exercise_id: int,
set_number: int,
reps_completed: int,
weight_used: str,
felt_easy: bool,
notes: Optional[str] = None,
) -> WorkoutLog:
"""Create a new set log entry.
Args:
session_id: FK to workout_sessions.
exercise_id: FK to exercises.
set_number: Which set (1, 2, 3...).
reps_completed: Actual reps performed.
weight_used: Weight as string (e.g., "30 lbs").
felt_easy: Whether the set felt easy.
notes: Optional notes for this set.
Returns:
The newly created WorkoutLog record.
"""
log = WorkoutLog(
session_id=session_id,
exercise_id=exercise_id,
set_number=set_number,
reps_completed=reps_completed,
weight_used=weight_used,
felt_easy=felt_easy,
notes=notes,
)
self._session.add(log)
self._session.commit()
self._session.refresh(log)
logger.info(
"log_created",
session_id=session_id,
exercise_id=exercise_id,
set=set_number,
)
return log
def list_logs_for_session(self, session_id: int) -> list[WorkoutLog]:
"""List all log entries for a workout session.
Args:
session_id: The workout session ID.
Returns:
List of WorkoutLog records ordered by exercise and set number.
"""
statement = (
select(WorkoutLog)
.where(WorkoutLog.session_id == session_id)
.order_by(WorkoutLog.exercise_id, WorkoutLog.set_number)
)
return list(self._session.exec(statement).all())
def list_logs_for_exercise(
self,
session_id: int,
exercise_id: int,
) -> list[WorkoutLog]:
"""List log entries for a specific exercise within a session.
Args:
session_id: The workout session ID.
exercise_id: The exercise ID.
Returns:
List of WorkoutLog records for this exercise, ordered by set.
"""
statement = (
select(WorkoutLog)
.where(
WorkoutLog.session_id == session_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutLog.set_number)
)
return list(self._session.exec(statement).all())
def get_log_by_id(self, log_id: int) -> Optional[WorkoutLog]:
"""Retrieve a log entry by primary key.
Args:
log_id: The log entry ID.
Returns:
The WorkoutLog record, or None if not found.
"""
return self._session.get(WorkoutLog, log_id)
def update_log(self, log_id: int, **kwargs) -> WorkoutLog:
"""Update fields on an existing log entry.
Args:
log_id: The log entry ID.
**kwargs: Field names and new values.
Returns:
The updated WorkoutLog record.
Raises:
ValueError: If the log is not found.
"""
log = self.get_log_by_id(log_id)
if log is None:
raise ValueError(f"WorkoutLog with id {log_id} not found")
for key, value in kwargs.items():
if hasattr(log, key):
setattr(log, key, value)
self._session.add(log)
self._session.commit()
self._session.refresh(log)
logger.info("log_updated", log_id=log_id, fields=list(kwargs.keys()))
return log
def delete_log(self, log_id: int) -> None:
"""Delete a log entry.
Removes the log, renumbers remaining sets, and cleans up the
parent session if no logs remain.
Args:
log_id: The log entry ID.
Raises:
ValueError: If the log is not found.
"""
log = self.get_log_by_id(log_id)
if log is None:
raise ValueError(f"WorkoutLog with id {log_id} not found")
session_id = log.session_id
exercise_id = log.exercise_id
self._session.delete(log)
self._session.commit()
logger.info("log_deleted", log_id=log_id)
# Renumber remaining sets so they stay sequential (1, 2, 3...)
remaining = self._session.exec(
select(WorkoutLog)
.where(
WorkoutLog.session_id == session_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutLog.set_number)
).all()
for i, remaining_log in enumerate(remaining, start=1):
if remaining_log.set_number != i:
remaining_log.set_number = i
self._session.add(remaining_log)
if remaining:
self._session.commit()
# Clean up orphaned session if no logs remain for ANY exercise
any_remaining = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == session_id)
).first()
if any_remaining is None:
ws = self._session.get(WorkoutSession, session_id)
if ws:
self._session.delete(ws)
self._session.commit()
logger.info("session_deleted_empty", session_id=session_id)
def get_latest_logs_for_exercise(
self,
user_id: int,
exercise_id: int,
limit: int = 10,
) -> list[WorkoutLog]:
"""Get the most recent log entries for an exercise across sessions.
Used by the progression engine (Phase 5) to determine
what the user last did for this exercise.
Args:
user_id: The user's ID.
exercise_id: The exercise ID.
limit: Maximum number of logs to return.
Returns:
List of recent WorkoutLog records, newest first.
"""
statement = (
select(WorkoutLog)
.join(WorkoutSession, WorkoutLog.session_id == WorkoutSession.id)
.where(
WorkoutSession.user_id == user_id,
WorkoutLog.exercise_id == exercise_id,
)
.order_by(WorkoutSession.date.desc(), WorkoutLog.set_number)
.limit(limit)
)
return list(self._session.exec(statement).all())

View File

@@ -0,0 +1,302 @@
"""Auto-progression engine using a rep ladder model.
Every exercise follows the same 6 → 8 → 10 → 12 rep ladder at current weight.
At 12 reps with all sets felt easy, weight increases by 5 lbs and reps reset to 6.
Deload triggers after 4+ consecutive struggling sessions (-20% weight, reset to 6).
"""
import re
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.progress_log import ProgressLog
from app.models.user_exercise_program import UserExerciseProgram
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
REP_LADDER = [6, 8, 10, 12]
SETS_PER_EXERCISE = 3
WEIGHT_INCREMENT = 5
DELOAD_FACTOR = 0.8
STRUGGLE_THRESHOLD = 4
def _parse_weight(weight_str: str) -> Optional[float]:
"""Extract numeric weight from a string like '30 lbs' or 'BW'.
Args:
weight_str: Weight as a string.
Returns:
Numeric weight in lbs, or None for bodyweight.
"""
if not weight_str or weight_str.upper() == "BW":
return None
match = re.search(r"(\d+(?:\.\d+)?)", weight_str)
return float(match.group(1)) if match else None
def _format_weight(weight_lbs: Optional[float]) -> str:
"""Format a numeric weight back to a display string.
Args:
weight_lbs: Weight in lbs, or None for bodyweight.
Returns:
Formatted string like '35 lbs' or 'BW'.
"""
if weight_lbs is None:
return "BW"
if weight_lbs == int(weight_lbs):
return f"{int(weight_lbs)} lbs"
return f"{weight_lbs:.1f} lbs"
def _snap_to_ladder(reps: int) -> int:
"""Clamp reps into the ladder range [6, 12]."""
return max(REP_LADDER[0], min(reps, REP_LADDER[-1]))
def _ladder_position(reps: int) -> int:
"""Return the index (0-3) of reps in REP_LADDER, or -1 if outside."""
snapped = _snap_to_ladder(reps)
try:
return REP_LADDER.index(snapped)
except ValueError:
# reps is in range but not on a ladder step (e.g. 7, 9, 11)
# find the highest step at or below current reps
for i in range(len(REP_LADDER) - 1, -1, -1):
if REP_LADDER[i] <= snapped:
return i
return -1
class ProgressionService:
"""Implements the rep ladder auto-progression engine.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def _get_program(
self, user_id: int, exercise_id: int,
) -> Optional[UserExerciseProgram]:
"""Look up the user's program for a specific exercise."""
statement = select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user_id,
UserExerciseProgram.exercise_id == exercise_id,
)
return self._session.exec(statement).first()
def _get_recent_sessions(
self, user_id: int, exercise_id: int, limit: int = 5,
) -> list[dict]:
"""Get recent session summaries for an exercise.
Returns a list of dicts with: date, avg_reps, weight, all_felt_easy.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit * 2)
)
sessions = self._session.exec(statement).all()
results = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(
WorkoutLog.session_id == ws.id,
WorkoutLog.exercise_id == exercise_id,
)
).all()
if not logs:
continue
avg_reps = sum(log.reps_completed for log in logs) / len(logs)
weight = logs[0].weight_used
all_felt_easy = all(log.felt_easy for log in logs)
results.append({
"date": ws.date,
"avg_reps": avg_reps,
"weight": weight,
"all_felt_easy": all_felt_easy,
"set_count": len(logs),
})
if len(results) >= limit:
break
return results
def get_suggestion(
self, user_id: int, exercise_id: int,
) -> dict:
"""Generate a progression suggestion using the rep ladder model.
Returns:
Dict with keys: suggested_reps, suggested_weight, suggested_sets,
ladder_position, progression_type, message.
"""
program = self._get_program(user_id, exercise_id)
if program is None:
return {
"suggested_reps": 0,
"suggested_weight": "",
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": -1,
"progression_type": "no_program",
"message": "No program found for this exercise.",
}
starting_weight = program.starting_weight
recent = self._get_recent_sessions(user_id, exercise_id, limit=5)
# No history — baseline suggestion
if not recent:
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": starting_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "baseline",
"message": f"Start with {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ {starting_weight}.",
}
latest = recent[0]
current_reps = _snap_to_ladder(int(round(latest["avg_reps"])))
current_weight = latest["weight"]
current_weight_num = _parse_weight(current_weight)
all_felt_easy = latest["all_felt_easy"]
# Count consecutive struggling sessions (not felt easy)
struggle_count = 0
for s in recent:
if not s["all_felt_easy"]:
struggle_count += 1
else:
break
# Deload: 4+ consecutive struggling sessions
if struggle_count >= STRUGGLE_THRESHOLD:
if current_weight_num is not None:
deload_weight = current_weight_num * DELOAD_FACTOR
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": _format_weight(deload_weight),
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "deload",
"message": (
f"Deload: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
f"{_format_weight(deload_weight)} (-20%)."
),
}
# Bodyweight — can't reduce weight, just reset reps
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "deload",
"message": f"Deload: reset to {SETS_PER_EXERCISE}x{REP_LADDER[0]}.",
}
# At top of ladder (12 reps) and felt easy
if current_reps >= REP_LADDER[-1] and all_felt_easy:
if current_weight_num is not None:
new_weight = current_weight_num + WEIGHT_INCREMENT
return {
"suggested_reps": REP_LADDER[0],
"suggested_weight": _format_weight(new_weight),
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": 0,
"progression_type": "weight_increase",
"message": (
f"Weight up: {SETS_PER_EXERCISE}x{REP_LADDER[0]} @ "
f"{_format_weight(new_weight)} (+{WEIGHT_INCREMENT} lbs)."
),
}
# Bodyweight — hold at top
return {
"suggested_reps": REP_LADDER[-1],
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": len(REP_LADDER) - 1,
"progression_type": "hold_at_top",
"message": (
f"Hold: {SETS_PER_EXERCISE}x{REP_LADDER[-1]} @ {current_weight} "
f"(bodyweight max)."
),
}
# Below top and felt easy — climb to next ladder step
if all_felt_easy:
pos = _ladder_position(current_reps)
next_pos = min(pos + 1, len(REP_LADDER) - 1)
next_reps = REP_LADDER[next_pos]
return {
"suggested_reps": next_reps,
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": next_pos,
"progression_type": "climb",
"message": (
f"Climb: {SETS_PER_EXERCISE}x{next_reps} @ {current_weight}."
),
}
# Not all felt easy — hold at current
pos = _ladder_position(current_reps)
return {
"suggested_reps": current_reps,
"suggested_weight": current_weight,
"suggested_sets": SETS_PER_EXERCISE,
"ladder_position": pos,
"progression_type": "hold",
"message": f"Hold: {SETS_PER_EXERCISE}x{current_reps} @ {current_weight}.",
}
def record_progression(
self,
user_id: int,
exercise_id: int,
suggested_reps: int,
suggested_weight: str,
actual_reps: int,
actual_weight: str,
progression_type: str,
) -> ProgressLog:
"""Record a progression entry in the progress_log table."""
progress_log = ProgressLog(
user_id=user_id,
exercise_id=exercise_id,
date=date.today(),
suggested_reps=suggested_reps,
suggested_weight=suggested_weight,
actual_reps=actual_reps,
actual_weight=actual_weight,
progression_applied=progression_type,
)
self._session.add(progress_log)
self._session.commit()
self._session.refresh(progress_log)
logger.info(
"progression_recorded",
user_id=user_id,
exercise_id=exercise_id,
type=progression_type,
)
return progress_log

View File

@@ -0,0 +1,174 @@
"""Service for seeding the database from YAML config files.
Reads config/exercises.yaml and config/user_programs.yaml to populate
the exercise library, warmups, workout days, and user programs.
"""
from pathlib import Path
from typing import Optional
import structlog
import yaml
from sqlmodel import Session, select
from app.models.exercise import Exercise
from app.models.user import User
from app.models.user_exercise_program import UserExerciseProgram
from app.models.warmup import Warmup
from app.models.workout_day import WorkoutDay
logger = structlog.get_logger(__name__)
# Default workout day definitions
WORKOUT_DAYS = [
{"name": "Push", "day_number": 1, "description": "Chest, shoulders, triceps"},
{"name": "Pull", "day_number": 2, "description": "Back, biceps, traps"},
{"name": "Lower", "day_number": 3, "description": "Quads, hamstrings, glutes, calves"},
{"name": "Full Body", "day_number": 4, "description": "Compound full-body movements"},
]
class SeedService:
"""Seeds the database from YAML configuration files.
Args:
session: An active SQLModel Session.
config_dir: Path to the config directory containing YAML files.
"""
def __init__(self, session: Session, config_dir: Optional[Path] = None) -> None:
self._session = session
self._config_dir = config_dir or Path(__file__).resolve().parent.parent.parent / "config"
def _load_yaml(self, filename: str) -> dict:
"""Load and parse a YAML file from the config directory."""
filepath = self._config_dir / filename
with open(filepath, "r") as f:
return yaml.safe_load(f)
def seed_workout_days(self) -> None:
"""Seed the 4 workout day records if they don't already exist."""
existing = self._session.exec(select(WorkoutDay)).first()
if existing:
logger.info("seed_skipped", table="workout_days", reason="already seeded")
return
for day_data in WORKOUT_DAYS:
day = WorkoutDay(**day_data)
self._session.add(day)
self._session.commit()
logger.info("seed_complete", table="workout_days", count=len(WORKOUT_DAYS))
def seed_exercises(self) -> None:
"""Seed exercises from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Exercise)).first()
if existing:
logger.info("seed_skipped", table="exercises", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
exercises = data.get("exercises", [])
for ex in exercises:
exercise = Exercise(
name=ex["name"],
muscle_group=ex["muscle_group"],
workout_day=ex["workout_day"],
sets=ex.get("sets", 3),
tempo=ex.get("tempo", ""),
form_cues=ex.get("form_cues", "").strip(),
)
self._session.add(exercise)
self._session.commit()
logger.info("seed_complete", table="exercises", count=len(exercises))
def seed_warmups(self) -> None:
"""Seed warmups from config/exercises.yaml if table is empty."""
existing = self._session.exec(select(Warmup)).first()
if existing:
logger.info("seed_skipped", table="warmups", reason="already seeded")
return
data = self._load_yaml("exercises.yaml")
warmups = data.get("warmup", [])
for i, wu in enumerate(warmups, start=1):
warmup = Warmup(
name=wu["name"],
type=wu.get("type", ""),
reps=wu.get("reps", ""),
form_cues=wu.get("form_cues", "").strip(),
sort_order=i,
)
self._session.add(warmup)
self._session.commit()
logger.info("seed_complete", table="warmups", count=len(warmups))
def seed_user_programs(self) -> None:
"""Seed user profiles and their exercise programs from user_programs.yaml."""
data = self._load_yaml("user_programs.yaml")
programs = data.get("programs", [])
for program in programs:
username = program["user"].lower().replace(" ", "_")
display_name = program["user"]
profile = program.get("profile", {})
# Check if user already exists
existing_user = self._session.exec(
select(User).where(User.username == username)
).first()
if existing_user:
user = existing_user
logger.info("seed_skipped", table="users", user=username, reason="already exists")
else:
user = User(
username=username,
display_name=display_name,
height=profile.get("height", ""),
weight=profile.get("weight", ""),
goals=profile.get("goals", ""),
)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("seed_complete", table="users", user=username)
# Link exercises to user
for ex_data in program.get("exercises", []):
exercise = self._session.exec(
select(Exercise).where(Exercise.name == ex_data["name"])
).first()
if exercise is None:
logger.warning("seed_exercise_not_found", name=ex_data["name"])
continue
# Check if program already exists
existing_program = self._session.exec(
select(UserExerciseProgram).where(
UserExerciseProgram.user_id == user.id,
UserExerciseProgram.exercise_id == exercise.id,
)
).first()
if existing_program:
continue
uep = UserExerciseProgram(
user_id=user.id,
exercise_id=exercise.id,
starting_weight=str(ex_data.get("starting_weight", "")),
)
self._session.add(uep)
self._session.commit()
logger.info("seed_complete", table="user_exercise_programs", user=username)
def seed_all(self) -> None:
"""Run all seed operations in the correct order."""
logger.info("seed_all_started")
self.seed_workout_days()
self.seed_exercises()
self.seed_warmups()
self.seed_user_programs()
logger.info("seed_all_complete")

View File

@@ -0,0 +1,99 @@
"""Service layer for user profile management.
All user database operations go through this service.
Routes should never query the users table directly.
"""
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.user import User
logger = structlog.get_logger(__name__)
class UserService:
"""Handles CRUD operations for User records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def create_user(
self,
username: str,
display_name: str,
height: Optional[str] = None,
weight: Optional[str] = None,
goals: Optional[str] = None,
) -> User:
"""Create a new user profile.
Args:
username: Unique identifier.
display_name: Human-readable name.
height: User height as string.
weight: User weight as string.
goals: Free-text goals.
Returns:
The newly created User record.
"""
user = User(
username=username,
display_name=display_name,
height=height,
weight=weight,
goals=goals,
)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("user_created", username=username)
return user
def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Retrieve a user by primary key."""
return self._session.get(User, user_id)
def get_user_by_username(self, username: str) -> Optional[User]:
"""Retrieve a user by username."""
statement = select(User).where(User.username == username)
return self._session.exec(statement).first()
def list_users(self) -> list[User]:
"""List all user profiles."""
statement = select(User)
return list(self._session.exec(statement).all())
def update_user(self, user_id: int, **kwargs) -> User:
"""Update fields on an existing user.
Args:
user_id: The user's ID.
**kwargs: Field names and new values to update.
Returns:
The updated User record.
Raises:
ValueError: If the user is not found.
"""
user = self.get_user_by_id(user_id)
if user is None:
raise ValueError(f"User with id {user_id} not found")
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
self._session.add(user)
self._session.commit()
self._session.refresh(user)
logger.info("user_updated", user_id=user_id, fields=list(kwargs.keys()))
return user

View File

@@ -0,0 +1,143 @@
"""Service layer for workout session management.
Handles creation, retrieval, and updates for workout sessions.
A session represents a single workout on a specific date for a user.
"""
from datetime import date
from typing import Optional
import structlog
from sqlmodel import Session, select
from app.models.workout_log import WorkoutLog
from app.models.workout_session import WorkoutSession
logger = structlog.get_logger(__name__)
class WorkoutSessionService:
"""Handles CRUD operations for WorkoutSession records.
Args:
session: An active SQLModel Session.
"""
def __init__(self, session: Session) -> None:
self._session = session
def get_or_create_session(
self,
user_id: int,
workout_day_id: int,
session_date: date,
) -> WorkoutSession:
"""Get an existing session or create a new one.
If a session already exists for this user + day + date combo,
return it. Otherwise, create a new one. This allows logging
to start automatically without an explicit "start session" step.
Args:
user_id: The user's ID.
workout_day_id: The workout day's ID.
session_date: The date of the workout.
Returns:
The existing or newly created WorkoutSession.
"""
statement = select(WorkoutSession).where(
WorkoutSession.user_id == user_id,
WorkoutSession.workout_day_id == workout_day_id,
WorkoutSession.date == session_date,
)
existing = self._session.exec(statement).first()
if existing:
return existing
ws = WorkoutSession(
user_id=user_id,
workout_day_id=workout_day_id,
date=session_date,
)
self._session.add(ws)
self._session.commit()
self._session.refresh(ws)
logger.info(
"workout_session_created",
user_id=user_id,
day_id=workout_day_id,
date=str(session_date),
)
return ws
def get_last_completed_session(self, user_id: int) -> Optional[WorkoutSession]:
"""Get the most recent session that has at least one logged set."""
statement = (
select(WorkoutSession)
.join(WorkoutLog, WorkoutLog.session_id == WorkoutSession.id)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(1)
)
return self._session.exec(statement).first()
def list_sessions(
self,
user_id: int,
limit: int = 50,
) -> list[WorkoutSession]:
"""List workout sessions for a user, most recent first.
Args:
user_id: The user's ID.
limit: Maximum number of sessions to return.
Returns:
List of WorkoutSession records, ordered by date descending.
"""
statement = (
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
.limit(limit)
)
return list(self._session.exec(statement).all())
def get_session_by_id(self, session_id: int) -> Optional[WorkoutSession]:
"""Retrieve a workout session by primary key.
Args:
session_id: The session ID.
Returns:
The WorkoutSession record, or None if not found.
"""
return self._session.get(WorkoutSession, session_id)
def update_session(self, session_id: int, **kwargs) -> WorkoutSession:
"""Update fields on an existing workout session.
Args:
session_id: The session ID.
**kwargs: Field names and new values.
Returns:
The updated WorkoutSession record.
Raises:
ValueError: If the session is not found.
"""
ws = self.get_session_by_id(session_id)
if ws is None:
raise ValueError(f"WorkoutSession with id {session_id} not found")
for key, value in kwargs.items():
if hasattr(ws, key):
setattr(ws, key, value)
self._session.add(ws)
self._session.commit()
self._session.refresh(ws)
return ws

View File

@@ -0,0 +1,69 @@
/* SneakySwole custom CSS overrides
Built on top of Pico CSS dark theme.
Keep Pico mostly untouched — only override what's necessary. */
/* Slightly bolder nav styling */
nav h1 {
margin-bottom: 0;
font-weight: 700;
}
/* Active nav link indicator */
nav a[aria-current="page"] {
font-weight: 700;
text-decoration: underline;
}
/* Spacing for main content area */
main.container {
padding-top: 1rem;
padding-bottom: 2rem;
}
/* Flash message styling */
.flash-success {
color: var(--pico-ins-color);
border-left: 4px solid var(--pico-ins-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}
.flash-error {
color: var(--pico-del-color);
border-left: 4px solid var(--pico-del-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}
/* Muscle group heatmap */
.muscle-heatmap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.muscle-heatmap-cell {
padding: 1rem;
border-radius: 0.5rem;
text-align: center;
}
.recency-fresh {
background: rgba(34, 197, 94, 0.3);
}
.recency-ok {
background: rgba(234, 179, 8, 0.3);
}
.recency-stale {
background: rgba(249, 115, 22, 0.3);
}
.recency-overdue {
background: rgba(239, 68, 68, 0.3);
}
.recency-never {
background: rgba(107, 114, 128, 0.3);
}

49
app/templates/base.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakySwole{% endblock %}</title>
<!-- Pico CSS (dark theme via data-theme="dark" on <html>) -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom overrides -->
<link rel="stylesheet" href="/static/css/sneakyswole.css">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="container">
<nav>
<ul>
<li><strong><a href="/">SneakySwole</a></strong></li>
</ul>
<ul>
{% block nav_items %}
{% include "partials/nav.html" ignore missing %}
{% endblock %}
</ul>
</nav>
</header>
<main class="container">
{% block flash %}
{% include "partials/flash_message.html" ignore missing %}
{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>SneakySwole — Open-source workout tracking</small>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Dashboard -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>Progress Dashboard</h1>
{% if active_profile %}
<p>{{ active_profile.display_name }}'s training overview</p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% if stats %}
<!-- Summary Stats -->
<div class="grid">
{% include "partials/stats_card.html" %}
</div>
<!-- Volume by Day Chart -->
<article>
<header><h3>Volume by Workout Day</h3></header>
{% include "partials/volume_chart.html" %}
</article>
<!-- Recent Activity -->
{% include "partials/recent_activity.html" %}
<!-- Progression Timeline -->
{% include "partials/progression_chart.html" %}
<!-- Muscle Group Heatmap -->
{% include "partials/muscle_heatmap.html" %}
<!-- Exercise Progress Links -->
<article>
<header><h3>Per-Exercise Progress</h3></header>
<ul>
{% for exercise in exercises %}
<li>
<a href="/dashboard/exercise/{{ exercise.id }}">
{{ exercise.name }}
</a>
<small> -- {{ exercise.workout_day }} Day</small>
</li>
{% endfor %}
</ul>
</article>
<!-- Export -->
{% include "partials/export_form.html" %}
{% endif %}
<!-- Import -->
{% include "partials/import_form.html" %}
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Exercise Library — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>Exercise Library</h1>
<p>Browse all exercises with form cues and programming details.</p>
</hgroup>
<!-- HTMX-powered filters -->
<div class="grid">
<select name="workout_day"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='muscle_group']">
<option value="">All Days</option>
{% for day in workout_days %}
<option value="{{ day.name }}">{{ day.name }}</option>
{% endfor %}
</select>
<select name="muscle_group"
hx-get="/exercises/search"
hx-target="#exercise-results"
hx-include="[name='workout_day']">
<option value="">All Muscle Groups</option>
{% for mg in muscle_groups %}
<option value="{{ mg }}">{{ mg }}</option>
{% endfor %}
</select>
</div>
<!-- Exercise results (replaced by HTMX on filter change) -->
<div id="exercise-results">
{% include "partials/exercise_list.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ exercise.name }} Progress -- SneakySwole{% endblock %}
{% block head_extra %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<hgroup>
<h1>{{ exercise.name }}</h1>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day</p>
</hgroup>
<!-- Progression Suggestion -->
{% if suggestion %}
<article>
<header><h3>Next Workout Suggestion</h3></header>
<p>{{ suggestion.message }}</p>
<div class="grid">
<div>
<small>Suggested Reps</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_reps }}
</p>
</div>
<div>
<small>Suggested Weight</small>
<p style="font-size:1.5rem; font-weight:700;">
{{ suggestion.suggested_weight }}
</p>
</div>
<div>
<small>Progression Type</small>
<p><mark>{{ suggestion.progression_type }}</mark></p>
</div>
</div>
</article>
{% endif %}
<!-- Progress Chart -->
<article>
<header><h3>Rep and Weight Trends</h3></header>
{% include "partials/progress_chart.html" %}
</article>
<a href="/dashboard" role="button" class="outline">Back to Dashboard</a>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Home{% endblock %}
{% block content %}
<hgroup>
<h1>SneakySwole</h1>
<p>Your open-source workout tracker</p>
</hgroup>
{% if profiles %}
<h2>Select Profile</h2>
<div class="grid">
{% for profile in profiles %}
<article>
<header>
<strong>{{ profile.display_name }}</strong>
</header>
{% if profile.height or profile.weight %}
<p>
{% if profile.height %}Height: {{ profile.height }}{% endif %}
{% if profile.height and profile.weight %} &middot; {% endif %}
{% if profile.weight %}Weight: {{ profile.weight }}{% endif %}
</p>
{% endif %}
<footer>
<form method="POST" action="/profiles/switch">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button type="submit" class="contrast">Select</button>
</form>
</footer>
</article>
{% endfor %}
</div>
{% else %}
<p>No profiles yet. Create one to get started.</p>
{% endif %}
<a href="/profiles/create" role="button" class="secondary">Create New Profile</a>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Workout History -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>Workout History</h1>
{% if active_profile %}
<p>History for: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected -- <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
{% if sessions %}
{% for ws in sessions %}
{% set day = days_by_id.get(ws.workout_day_id) %}
{% include "partials/session_card.html" %}
{% endfor %}
{% else %}
<p>No workout sessions recorded yet.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Create Profile{% endblock %}
{% block content %}
<hgroup>
<h1>Create Profile</h1>
<p>Set up a new workout profile</p>
</hgroup>
{% if error %}
<article aria-label="Error" class="pico-background-red-500">
<p>{{ error }}</p>
</article>
{% endif %}
<form method="POST" action="/profiles/create">
<label for="display_name">
Display Name <span aria-hidden="true">*</span>
<input type="text" id="display_name" name="display_name" required
placeholder="e.g. Phillip" value="{{ request.query_params.get('display_name', '') }}">
</label>
<label for="height">
Height
<input type="text" id="height" name="height"
placeholder="e.g. 6'0&quot;">
</label>
<label for="weight">
Weight
<input type="text" id="weight" name="weight"
placeholder="e.g. 260 lbs">
</label>
<label for="goals">
Goals
<textarea id="goals" name="goals" rows="3"
placeholder="e.g. Build strength, improve mobility"></textarea>
</label>
<button type="submit">Create Profile</button>
</form>
<p><a href="/">&larr; Back to profiles</a></p>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Edit Profile — {{ profile.display_name }}{% endblock %}
{% block content %}
<article>
<header>
<h2>Edit Profile: {{ profile.display_name }}</h2>
</header>
<form method="POST" action="/profiles/{{ profile.id }}/edit">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name"
value="{{ profile.display_name }}" required>
<label for="height">Height</label>
<input type="text" id="height" name="height"
value="{{ profile.height or '' }}" placeholder="e.g., 6'0&quot;">
<label for="weight">Weight</label>
<input type="text" id="weight" name="weight"
value="{{ profile.weight or '' }}" placeholder="e.g., 260 lbs">
<label for="goals">Goals</label>
<textarea id="goals" name="goals"
placeholder="Training goals...">{{ profile.goals or '' }}</textarea>
<div class="grid">
<a href="/profiles" role="button" class="outline secondary">Cancel</a>
<button type="submit">Save Changes</button>
</div>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}User Profiles — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>User Profiles</h1>
<p>Manage training profiles.</p>
</hgroup>
<div class="grid">
{% for profile in profiles %}
{% include "partials/profile_card.html" %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Session Detail -- SneakySwole{% endblock %}
{% block content %}
<hgroup>
{% set day = days_by_id.get(workout_session.workout_day_id) %}
<h1>
{{ day.name if day else "Workout" }}
-- {{ workout_session.date.strftime('%B %d, %Y') }}
</h1>
{% if workout_session.notes %}
<p>{{ workout_session.notes }}</p>
{% endif %}
</hgroup>
{% for exercise_id, logs in logs_by_exercise.items() %}
{% set exercise = exercises_by_id[exercise_id] %}
<article>
<header>
<h3>{{ exercise.name }}</h3>
</header>
<table>
<thead>
<tr>
<th>Set</th>
<th>Reps</th>
<th>Weight</th>
<th>Easy?</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.set_number }}</td>
<td>{{ log.reps_completed }}</td>
<td>{{ log.weight_used }}</td>
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
<td>{{ log.notes or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
{% else %}
<p>No exercises logged in this session.</p>
{% endfor %}
<a href="/history" role="button" class="outline">Back to History</a>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{{ day_name }} Day — SneakySwole{% endblock %}
{% block content %}
<hgroup>
<h1>{{ day_name }} Day</h1>
{% if active_profile %}
<p>Training as: <strong>{{ active_profile.display_name }}</strong></p>
{% else %}
<p>No profile selected — <a href="/profiles">select one</a></p>
{% endif %}
</hgroup>
<!-- Warmup Section -->
<section>
<h2>Warmup</h2>
{% include "partials/warmup_list.html" %}
</section>
<!-- Exercises Section -->
<section>
<h2>Exercises</h2>
{% for exercise in exercises %}
{% set program = programs.get(exercise.id) %}
{% include "partials/exercise_card.html" %}
{% endfor %}
</section>
<a href="/workouts" role="button" class="outline">Change Workout</a>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Workout Now — SneakySwole{% endblock %}
{% block content %}
<h1>Workout Now</h1>
<p>
{% if last_workout_name %}
Last workout: <strong>{{ last_workout_name }}</strong> on {{ last_workout_date.strftime('%b %-d') }}
{% else %}
No workouts yet — start with Push!
{% endif %}
</p>
<div class="grid">
{% for day in days %}
<article>
<header>
<h3>Day {{ day.day_number }}: {{ day.name }}</h3>
{% if day.id == recommended_day_id %}
<mark>Recommended Next</mark>
{% endif %}
</header>
<p>{{ day.description }}</p>
<footer>
{% if day.id == recommended_day_id %}
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
role="button">Start Workout</a>
{% else %}
<a href="/workouts/{{ day.name|lower|replace(' ', '-') }}"
role="button" class="outline">Start Workout</a>
{% endif %}
</footer>
</article>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
{% if program %}
{% set suggestion = suggestions[exercise.id] if suggestions and exercise.id in suggestions else None %}
{% set pos = suggestion.ladder_position if suggestion and suggestion.ladder_position is defined else -1 %}
{% if pos >= 0 %}
<div style="display: flex; gap: 0.25rem; align-items: center; margin-bottom: 0.75rem;">
{% for step in [6, 8, 10, 12] %}
<div style="flex: 1; text-align: center; padding: 0.35rem 0;
border-radius: 0.25rem; font-size: 0.85rem; font-weight: 600;
{% if loop.index0 <= pos %}
background: var(--pico-primary); color: var(--pico-primary-inverse);
{% else %}
background: var(--pico-muted-border-color); color: var(--pico-muted-color);
{% endif %}">
{{ step }}
</div>
{% endfor %}
<small style="margin-left: 0.5rem; white-space: nowrap;">@ {{ suggestion.suggested_weight }}</small>
</div>
{% else %}
<p><small>Starting weight: {{ program.starting_weight }}</small></p>
{% endif %}
{% endif %}
{% if suggestions and suggestions[exercise.id] %}
{% set suggestion = suggestions[exercise.id] %}
{% include "partials/progression_badge.html" %}
{% endif %}
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
<!-- Inline logging (Phase 4) -->
{% if active_profile %}
<div id="logs-exercise-{{ exercise.id }}">
{% if suggestions and suggestions[exercise.id] %}
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
{% endif %}
{% if existing_logs and existing_logs[exercise.id] %}
{% set suggested_reps = existing_logs[exercise.id][-1].reps_completed %}
{% set suggested_weight = existing_logs[exercise.id][-1].weight_used %}
{% elif suggestions and suggestions[exercise.id] %}
{% set suggested_reps = suggestions[exercise.id].suggested_reps %}
{% set suggested_weight = suggestions[exercise.id].suggested_weight %}
{% endif %}
{% if existing_logs and existing_logs[exercise.id] %}
{% set logs = existing_logs[exercise.id] %}
{% set exercise_id = exercise.id %}
{% set next_set = logs|length + 1 %}
{% include "partials/log_entry.html" %}
{% else %}
{% set exercise_id = exercise.id %}
{% set next_set = 1 %}
{% include "partials/log_form.html" %}
{% endif %}
</div>
{% endif %}
</article>

View File

@@ -0,0 +1,16 @@
{% for exercise in exercises %}
<article>
<header>
<hgroup>
<h3>{{ exercise.name }}</h3>
<p>{{ exercise.muscle_group }} | {{ exercise.workout_day }} Day | {{ exercise.sets }} sets | Tempo: {{ exercise.tempo }}</p>
</hgroup>
</header>
<details>
<summary>Form Cues</summary>
<p>{{ exercise.form_cues }}</p>
</details>
</article>
{% else %}
<p>No exercises found matching your filters.</p>
{% endfor %}

View File

@@ -0,0 +1,19 @@
<article>
<header><h3>Export Workout History</h3></header>
<form method="get" action="/dashboard/export">
<div class="grid">
<label>
Start Date
<input type="date" name="start_date" value="{{ export_start_date }}">
</label>
<label>
End Date
<input type="date" name="end_date" value="{{ export_end_date }}">
</label>
<label>
&nbsp;
<button type="submit">Download CSV</button>
</label>
</div>
</form>
</article>

View File

@@ -0,0 +1,6 @@
{% if flash_success %}
<div class="flash-success" role="status">{{ flash_success }}</div>
{% endif %}
{% if flash_error %}
<div class="flash-error" role="alert">{{ flash_error }}</div>
{% endif %}

View File

@@ -0,0 +1,17 @@
<article>
<header><h3>Import Old Database</h3></header>
<form hx-post="/dashboard/import" hx-encoding="multipart/form-data"
hx-target="#import-results" hx-swap="innerHTML">
<div class="grid">
<label>
Old Database File (.db)
<input type="file" name="db_file" accept=".db" required>
</label>
<label>
&nbsp;
<button type="submit">Import</button>
</label>
</div>
</form>
<div id="import-results"></div>
</article>

View File

@@ -0,0 +1,37 @@
<table>
<thead>
<tr>
<th>Category</th>
<th>Imported</th>
<th>Skipped</th>
</tr>
</thead>
<tbody>
<tr>
<td>Workout Sessions</td>
<td>{{ result.sessions_imported }}</td>
<td>{{ result.sessions_skipped }}</td>
</tr>
<tr>
<td>Workout Logs</td>
<td>{{ result.logs_imported }}</td>
<td>{{ result.logs_skipped }}</td>
</tr>
<tr>
<td>Progress Log</td>
<td>{{ result.progress_imported }}</td>
<td>{{ result.progress_skipped }}</td>
</tr>
</tbody>
</table>
{% if result.warnings %}
<details>
<summary>Warnings ({{ result.warnings|length }})</summary>
<ul>
{% for warning in result.warnings %}
<li><small>{{ warning }}</small></li>
{% endfor %}
</ul>
</details>
{% endif %}

View File

@@ -0,0 +1,39 @@
<!-- Displays logged sets for a single exercise within a session -->
{% if logs %}
<table>
<thead>
<tr>
<th>Set</th>
<th>Reps</th>
<th>Weight</th>
<th>Easy?</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.set_number }}</td>
<td>{{ log.reps_completed }}</td>
<td>{{ log.weight_used }}</td>
<td>{{ "Yes" if log.felt_easy else "No" }}</td>
<td>
<form hx-post="/log/{{ log.id }}/delete"
hx-target="#logs-exercise-{{ exercise_id }}"
hx-swap="innerHTML"
hx-confirm="Delete this set?"
style="display:inline; margin:0;">
<button type="submit" class="outline secondary"
style="padding:0.2rem 0.5rem; font-size:0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Next set form -->
{% include "partials/log_form.html" %}

View File

@@ -0,0 +1,30 @@
<!-- Inline logging form, included inside each exercise_card.html -->
<form hx-post="/log"
hx-target="#logs-exercise-{{ exercise_id }}"
hx-swap="innerHTML"
style="margin-bottom:0;">
<input type="hidden" name="exercise_id" value="{{ exercise_id }}">
<input type="hidden" name="workout_day_id" value="{{ workout_day_id }}">
<input type="hidden" name="set_number" value="{{ next_set|default(1) }}">
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
<small style="white-space:nowrap; opacity:0.7;">Set {{ next_set|default(1) }}</small>
<input type="number" name="reps" placeholder="Reps"
min="0" max="100" required
{% if suggested_reps %}value="{{ suggested_reps }}"{% endif %}
style="width:5rem; margin-bottom:0;">
<input type="number" name="weight" placeholder="Weight (lbs)"
min="0" max="999" step="0.5" required
{% if suggested_weight and suggested_weight != "BW" %}
value="{{ suggested_weight|replace(' lbs', '') }}"
{% elif suggested_weight == "BW" %}
value="0"
{% endif %}
style="width:8rem; margin-bottom:0;">
<label style="display:flex; align-items:center; gap:0.3rem; margin-bottom:0; white-space:nowrap;">
<input type="checkbox" name="felt_easy" role="switch" style="margin-bottom:0;">
Easy?
</label>
<button type="submit" style="margin-bottom:0; width:auto; white-space:nowrap;">Log Set</button>
</div>
</form>

View File

@@ -0,0 +1,24 @@
<article>
<header><h3>Muscle Group Heatmap</h3></header>
{% if muscle_recency %}
<div class="muscle-heatmap">
{% for mg in muscle_recency %}
<div class="muscle-heatmap-cell {% if mg.days_ago is none %}recency-never{% elif mg.days_ago <= 3 %}recency-fresh{% elif mg.days_ago <= 7 %}recency-ok{% elif mg.days_ago <= 14 %}recency-stale{% else %}recency-overdue{% endif %}">
<strong>{{ mg.muscle_group }}</strong>
<br>
{% if mg.days_ago is none %}
<small>Never worked</small>
{% elif mg.days_ago == 0 %}
<small>Today</small>
{% elif mg.days_ago == 1 %}
<small>1 day ago</small>
{% else %}
<small>{{ mg.days_ago }} days ago</small>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p>No exercises found in the library.</p>
{% endif %}
</article>

View File

@@ -0,0 +1,32 @@
{% set profiles = request.state.profiles %}
{% set active_profile = request.state.active_profile %}
<li>
<details class="dropdown">
<summary>
{% if active_profile %}
{{ active_profile.display_name }}
{% else %}
Select Profile
{% endif %}
</summary>
<ul dir="rtl">
{% for profile in profiles %}
<li>
<form method="POST" action="/profiles/switch" style="margin:0;">
<input type="hidden" name="profile_id" value="{{ profile.id }}">
<button type="submit" class="outline secondary"
style="width:100%; text-align:left; border:none;">
{{ profile.display_name }}
</button>
</form>
</li>
{% endfor %}
</ul>
</details>
</li>
<li><a href="/">Home</a></li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/history">History</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>

View File

@@ -0,0 +1,12 @@
<article>
<header>
<hgroup>
<h3>{{ profile.display_name }}</h3>
<p>{{ profile.height }} | {{ profile.weight }}</p>
</hgroup>
</header>
<p>{{ profile.goals or "No goals set" }}</p>
<footer>
<a href="/profiles/{{ profile.id }}/edit" role="button" class="outline">Edit</a>
</footer>
</article>

View File

@@ -0,0 +1,56 @@
<canvas id="progress-chart" style="max-height:300px;"></canvas>
<p id="progress-chart-empty" style="display:none;">No data yet.</p>
<script>
(function() {
var data = {{ progress_data_json|safe }};
if (!data.dates || data.dates.length === 0) {
document.getElementById('progress-chart').style.display = 'none';
document.getElementById('progress-chart-empty').style.display = 'block';
return;
}
new Chart(document.getElementById('progress-chart'), {
type: 'line',
data: {
labels: data.dates,
datasets: [
{
label: 'Avg Reps',
data: data.reps,
borderColor: 'rgba(99, 102, 241, 1)',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
yAxisID: 'y-reps',
tension: 0.3
},
{
label: 'Weight (lbs)',
data: data.weights,
borderColor: 'rgba(244, 63, 94, 1)',
backgroundColor: 'rgba(244, 63, 94, 0.2)',
yAxisID: 'y-weight',
tension: 0.3
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
'y-reps': {
type: 'linear', display: true, position: 'left',
title: { display: true, text: 'Reps', color: '#ccc' },
ticks: { color: '#ccc' }
},
'y-weight': {
type: 'linear', display: true, position: 'right',
title: { display: true, text: 'Weight (lbs)', color: '#ccc' },
ticks: { color: '#ccc' },
grid: { drawOnChartArea: false }
},
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,20 @@
{% if suggestion and suggestion.progression_type != "no_program" %}
{% set badge_colors = {
"deload": "var(--pico-del-color)",
"weight_increase": "var(--pico-ins-color)",
"climb": "var(--pico-primary)",
"hold": "var(--pico-muted-color)",
"hold_at_top": "var(--pico-muted-color)",
"baseline": "var(--pico-primary)",
} %}
{% set border_color = badge_colors.get(suggestion.progression_type, "var(--pico-primary)") %}
<div style="background: rgba(99, 102, 241, 0.1);
border-left: 3px solid {{ border_color }};
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0 0.25rem 0.25rem 0;">
<small>
<strong>Suggestion:</strong> {{ suggestion.message }}
</small>
</div>
{% endif %}

View File

@@ -0,0 +1,90 @@
<article>
<header><h3>Progression Timeline</h3></header>
<div id="progression-container">
<canvas id="progression-chart" style="max-height:350px;"></canvas>
<div style="margin-top:0.5rem;">
<label for="progression-filter" style="display:inline; margin-right:0.5rem;">Exercise:</label>
<select id="progression-filter" style="display:inline-block; width:auto;">
<option value="all">All Exercises</option>
</select>
</div>
</div>
<p id="progression-empty" style="display:none;">No progression data yet.</p>
</article>
<script>
(function() {
var timeline = {{ progression_timeline_json|safe }};
var exerciseData = timeline.exercises || {};
var names = Object.keys(exerciseData);
if (names.length === 0) {
document.getElementById('progression-container').style.display = 'none';
document.getElementById('progression-empty').style.display = 'block';
return;
}
var colors = [
'rgba(99, 102, 241, 1)',
'rgba(34, 197, 94, 1)',
'rgba(234, 179, 8, 1)',
'rgba(249, 115, 22, 1)',
'rgba(239, 68, 68, 1)',
'rgba(168, 85, 247, 1)',
'rgba(20, 184, 166, 1)',
'rgba(236, 72, 153, 1)',
];
var datasets = [];
var filterSelect = document.getElementById('progression-filter');
names.forEach(function(name, i) {
var d = exerciseData[name];
datasets.push({
label: name,
data: d.dates.map(function(dt, j) {
return {x: dt, y: d.weights[j]};
}),
borderColor: colors[i % colors.length],
backgroundColor: colors[i % colors.length].replace('1)', '0.2)'),
tension: 0.3,
pointRadius: 3,
hidden: false,
});
var opt = document.createElement('option');
opt.value = i;
opt.textContent = name;
filterSelect.appendChild(opt);
});
var chart = new Chart(document.getElementById('progression-chart'), {
type: 'line',
data: {datasets: datasets},
options: {
responsive: true,
plugins: {
legend: {labels: {color: '#ccc'}},
},
scales: {
x: {
type: 'time',
time: {unit: 'week'},
ticks: {color: '#ccc'},
},
y: {
beginAtZero: false,
title: {display: true, text: 'Weight (lbs)', color: '#ccc'},
ticks: {color: '#ccc'},
},
},
},
});
filterSelect.addEventListener('change', function() {
var val = this.value;
chart.data.datasets.forEach(function(ds, i) {
ds.hidden = (val !== 'all' && i !== parseInt(val));
});
chart.update();
});
})();
</script>

View File

@@ -0,0 +1,29 @@
<article>
<header><h3>Recent Activity</h3></header>
{% if recent_activity %}
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Date</th>
<th>Workout</th>
<th>Volume (lbs)</th>
<th>Sets</th>
</tr>
</thead>
<tbody>
{% for session in recent_activity %}
<tr>
<td>{{ session.date.strftime('%b %d, %Y') }}</td>
<td>{{ session.workout_day_name }}</td>
<td>{{ "{:,}".format(session.total_volume) }}</td>
<td>{{ session.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No workout sessions logged yet.</p>
{% endif %}
</article>

View File

@@ -0,0 +1,16 @@
<article>
<header>
<hgroup>
<h3>{{ day.name if day else "Unknown" }} Day</h3>
<p>{{ ws.date.strftime('%A, %B %d, %Y') }}</p>
</hgroup>
</header>
{% if ws.notes %}
<p>{{ ws.notes }}</p>
{% endif %}
<footer>
<a href="/history/{{ ws.id }}" role="button" class="outline">
View Details
</a>
</footer>
</article>

View File

@@ -0,0 +1,53 @@
<article>
<header><h4>Sessions</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sessions }}
</p>
</article>
<article>
<header><h4>Total Sets</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.total_sets }}
</p>
</article>
<article>
<header><h4>Streak</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ stats.current_streak }} week{{ "s" if stats.current_streak != 1 }}
</p>
</article>
</div>
<div class="grid">
<article>
<header><h4>Last Workout</h4></header>
<p style="font-size:2rem; font-weight:700;">
{% if stats.last_workout_date %}
{{ stats.last_workout_date.strftime('%b %d') }}
{% else %}
Never
{% endif %}
</p>
</article>
<article>
<header><h4>Personal Records</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ personal_records|length }} PR{{ "s" if personal_records|length != 1 }}
</p>
{% if personal_records %}
<details>
<summary>View records</summary>
<ul style="font-size:0.85rem; margin-top:0.5rem;">
{% for pr in personal_records[:10] %}
<li>{{ pr.exercise_name }}: {{ pr.weight_display }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</article>
<article>
<header><h4>Adherence</h4></header>
<p style="font-size:2rem; font-weight:700;">
{{ adherence.rate }}%
</p>
<small>{{ adherence.completed }}/{{ adherence.expected }} sessions ({{ adherence.weeks }}wk)</small>
</article>

View File

@@ -0,0 +1,30 @@
<canvas id="volume-chart" style="max-height:300px;"></canvas>
<script>
(function() {
var data = {{ volume_data_json|safe }};
var labels = Object.keys(data);
var values = Object.values(data);
new Chart(document.getElementById('volume-chart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Total Volume (lbs)',
data: values,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderColor: 'rgba(99, 102, 241, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { color: '#ccc' } },
x: { ticks: { color: '#ccc' } }
}
}
});
})();
</script>

View File

@@ -0,0 +1,23 @@
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Type</th>
<th>Reps</th>
</tr>
</thead>
<tbody>
{% for warmup in warmups %}
<tr>
<td>
<details>
<summary>{{ warmup.name }}</summary>
<p>{{ warmup.form_cues }}</p>
</details>
</td>
<td>{{ warmup.type }}</td>
<td>{{ warmup.reps }}</td>
</tr>
{% endfor %}
</tbody>
</table>

0
app/utils/__init__.py Normal file
View File

62
app/utils/auth.py Normal file
View File

@@ -0,0 +1,62 @@
"""Profile selection utilities for FastAPI route protection.
Provides dependency functions that check the active_profile_id cookie
and return the selected User profile, or redirect to /.
"""
from typing import Optional
import structlog
from fastapi import Depends, Request
from sqlmodel import Session
from app.database import get_db_session
from app.models.user import User
logger = structlog.get_logger(__name__)
class NoProfileSelectedError(Exception):
"""Raised when a request lacks a valid profile selection."""
def get_active_profile_id(request: Request) -> Optional[int]:
"""Extract the active profile ID from the cookie.
Args:
request: The incoming HTTP request.
Returns:
The active profile user ID, or None if not set.
"""
profile_id = request.cookies.get("active_profile_id")
if profile_id and profile_id.isdigit():
return int(profile_id)
return None
def require_active_profile(request: Request, session: Session = Depends(get_db_session)) -> User:
"""FastAPI dependency that requires a valid profile selection.
Reads the active_profile_id cookie, loads the profile from DB,
and raises NoProfileSelectedError if missing or invalid.
Args:
request: The incoming HTTP request.
session: Database session (injected by FastAPI).
Returns:
The selected User profile.
Raises:
NoProfileSelectedError: If no valid profile is selected.
"""
profile_id = get_active_profile_id(request)
if profile_id is None:
raise NoProfileSelectedError()
user = session.get(User, profile_id)
if user is None:
raise NoProfileSelectedError()
return user

View File

@@ -1,5 +1,6 @@
# SneakySwole User Programs
# Per-user exercise programming with week 1 and week 4 targets.
# Per-user exercise programming with starting weights for rep ladder progression.
# Rep ladder: 6 → 8 → 10 → 12 reps at current weight, then +5 lbs and reset.
# Update this file to adjust programming, then re-run the seed script.
programs:
@@ -11,111 +12,51 @@ programs:
exercises:
# Day 1 — Push
- name: "DB Chest Press (Floor)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "30 lbs"
wk4_weight: "40 lbs"
starting_weight: "30 lbs"
- name: "DB Shoulder Press (Seated)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
- name: "DB Lateral Raise"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "10 lbs"
wk4_weight: "15 lbs"
starting_weight: "10 lbs"
- name: "Push-Up (Incline if needed)"
wk1_reps: 6
wk4_reps: 15
wk1_weight: "BW"
wk4_weight: "BW"
starting_weight: "BW"
- name: "DB Tricep Overhead Ext."
wk1_reps: 10
wk4_reps: 15
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
# Day 2 — Pull
- name: "DB Bent-Over Row (Supported)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "30 lbs"
wk4_weight: "45 lbs"
starting_weight: "30 lbs"
- name: "DB Rear Delt Fly"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "10 lbs"
wk4_weight: "15 lbs"
starting_weight: "10 lbs"
- name: "DB Hammer Curl"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
- name: "DB Bicep Curl"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
- name: "DB Shrug"
wk1_reps: 12
wk4_reps: 15
wk1_weight: "35 lbs"
wk4_weight: "50 lbs"
starting_weight: "35 lbs"
# Day 3 — Lower
- name: "Goblet Squat (DB)"
wk1_reps: 8
wk4_reps: 15
wk1_weight: "25 lbs"
wk4_weight: "40 lbs"
starting_weight: "25 lbs"
- name: "Romanian Deadlift (DB)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "25 lbs"
wk4_weight: "40 lbs"
starting_weight: "25 lbs"
- name: "Reverse Lunge (DB)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "Glute Bridge (DB on hips)"
wk1_reps: 12
wk4_reps: 20
wk1_weight: "25 lbs"
wk4_weight: "40 lbs"
starting_weight: "25 lbs"
- name: "Standing Calf Raise (DB)"
wk1_reps: 15
wk4_reps: 25
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
# Day 4 — Full Body
- name: "DB Thruster (Squat + Press)"
wk1_reps: 6
wk4_reps: 10
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
- name: "DB Renegade Row"
wk1_reps: 6
wk4_reps: 10
wk1_weight: "20 lbs"
wk4_weight: "30 lbs"
starting_weight: "20 lbs"
- name: "DB Rev. Lunge + Curl"
wk1_reps: 6
wk4_reps: 10
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "Dead Bug (BW)"
wk1_reps: 6
wk4_reps: 10
wk1_weight: "BW"
wk4_weight: "BW"
starting_weight: "BW"
- name: "DB Farmer's Carry"
wk1_reps: "30 sec"
wk4_reps: "45 sec"
wk1_weight: "30 lbs"
wk4_weight: "45 lbs"
starting_weight: "30 lbs"
- user: "Daughter"
profile:
@@ -125,108 +66,48 @@ programs:
exercises:
# Day 1 — Push
- name: "DB Chest Press (Floor)"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "DB Shoulder Press (Seated)"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "DB Lateral Raise"
wk1_reps: 12
wk4_reps: 18
wk1_weight: "8 lbs"
wk4_weight: "12 lbs"
starting_weight: "8 lbs"
- name: "Push-Up (Incline if needed)"
wk1_reps: 8
wk4_reps: 20
wk1_weight: "BW"
wk4_weight: "BW"
starting_weight: "BW"
- name: "DB Tricep Overhead Ext."
wk1_reps: 12
wk4_reps: 18
wk1_weight: "8 lbs"
wk4_weight: "15 lbs"
starting_weight: "8 lbs"
# Day 2 — Pull
- name: "DB Bent-Over Row (Supported)"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "DB Rear Delt Fly"
wk1_reps: 12
wk4_reps: 18
wk1_weight: "8 lbs"
wk4_weight: "12 lbs"
starting_weight: "8 lbs"
- name: "DB Hammer Curl"
wk1_reps: 12
wk4_reps: 18
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "DB Bicep Curl"
wk1_reps: 12
wk4_reps: 18
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "DB Shrug"
wk1_reps: 12
wk4_reps: 18
wk1_weight: "20 lbs"
wk4_weight: "35 lbs"
starting_weight: "20 lbs"
# Day 3 — Lower
- name: "Goblet Squat (DB)"
wk1_reps: 12
wk4_reps: 20
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "Romanian Deadlift (DB)"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
- name: "Reverse Lunge (DB)"
wk1_reps: 10
wk4_reps: 15
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "Glute Bridge (DB on hips)"
wk1_reps: 15
wk4_reps: 25
wk1_weight: "15 lbs"
wk4_weight: "30 lbs"
starting_weight: "15 lbs"
- name: "Standing Calf Raise (DB)"
wk1_reps: 20
wk4_reps: 30
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"
# Day 4 — Full Body
- name: "DB Thruster (Squat + Press)"
wk1_reps: 8
wk4_reps: 15
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "DB Renegade Row"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "10 lbs"
wk4_weight: "20 lbs"
starting_weight: "10 lbs"
- name: "DB Rev. Lunge + Curl"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "10 lbs"
wk4_weight: "18 lbs"
starting_weight: "10 lbs"
- name: "Dead Bug (BW)"
wk1_reps: 8
wk4_reps: 12
wk1_weight: "BW"
wk4_weight: "BW"
starting_weight: "BW"
- name: "DB Farmer's Carry"
wk1_reps: "30 sec"
wk4_reps: "45 sec"
wk1_weight: "15 lbs"
wk4_weight: "25 lbs"
starting_weight: "15 lbs"

0
data/.gitkeep Normal file
View File

29
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,29 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sneakyswole-dev
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- sneakyswole-data:/app/data
- ./app:/app/app
- ./config:/app/config
env_file:
- .env
environment:
- APP_ENV=development
- APP_LOG_LEVEL=debug
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneakyswole-data:
driver: local

43
docker-compose.yaml Normal file
View File

@@ -0,0 +1,43 @@
services:
caddy:
image: caddy:2-alpine
container_name: sneakyswole-proxy
ports:
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
- app
networks:
- sneakyswole
restart: unless-stopped
app:
image: git.sneakygeek.net/sneakygeek/sneakyswole:latest
container_name: sneakyswole
expose:
- "8000"
volumes:
- sneakyswole-data:/app/data
env_file:
- .env
networks:
- sneakyswole
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
sneakyswole:
driver: bridge
volumes:
sneakyswole-data:
driver: local
caddy-data:
driver: local

278
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,278 @@
# API Reference
All endpoints return HTML (full pages or HTMX partials) unless noted otherwise. Protected routes require a valid admin session cookie.
---
## Authentication
### `GET /login`
Render the login form.
- **Auth:** None
- **Response:** Full page (`pages/login.html`)
### `POST /login`
Authenticate admin credentials and start a session.
- **Auth:** None
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `username` (string) — admin username
- `password` (string) — admin password
- **Success:** 303 redirect to `/`, sets `session` cookie (httponly, samesite=lax, 24h TTL)
- **Failure:** 200 with login page re-rendered and error message
### `GET /logout`
End the admin session.
- **Auth:** None (clears cookies regardless)
- **Response:** 303 redirect to `/login`, deletes `session` and `active_profile_id` cookies
---
## Health
### `GET /health`
Application health check for monitoring and readiness probes.
- **Auth:** None
- **Response:** JSON
```json
{
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok"
}
```
---
## Pages
### `GET /`
Home page.
- **Auth:** None
- **Response:** Full page (`pages/home.html`)
---
## Profiles
All profile routes require admin authentication.
### `GET /profiles`
List all user profiles (excludes admin).
- **Auth:** Admin session
- **Response:** Full page (`pages/profiles.html`)
- **Template context:** `profiles`, `active_profile_id`, `admin`
### `POST /profiles/switch`
Switch the active user profile.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `profile_id` (string) — ID of the profile to activate
- **Response:** 303 redirect to referring page, sets `active_profile_id` cookie
### `GET /profiles/{profile_id}/edit`
Render the profile edit form.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Response:** Full page (`pages/profile_edit.html`)
### `POST /profiles/{profile_id}/edit`
Update a user profile.
- **Auth:** Admin session
- **Path params:** `profile_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `display_name` (string)
- `height` (string)
- `weight` (string)
- `goals` (string)
- **Response:** 303 redirect to `/profiles`
---
## Workouts
All workout routes require admin authentication.
### `GET /workouts`
List all workout days as clickable cards.
- **Auth:** Admin session
- **Response:** Full page (`pages/workout_days.html`)
- **Template context:** `days`, `admin`
### `GET /workouts/{day_name}`
Display a full workout day with warmups, exercises, programming targets, and inline logging.
- **Auth:** Admin session
- **Path params:** `day_name` (string) — URL-friendly name, e.g., "push", "pull", "lower", "full-body"
- **Response:** Full page (`pages/workout_day.html`)
- **Template context:** `day_name`, `warmups`, `exercises`, `programs`, `active_profile`, `existing_logs`, `suggestions`, `workout_day_id`, `admin`
- **Notes:** Day name is normalized (e.g., "full-body" → "Full Body"). If an active profile is set, includes programming targets, progression suggestions, and today's existing logs.
---
## Exercises
All exercise routes require admin authentication.
### `GET /exercises`
Render the exercise browser with filter dropdowns.
- **Auth:** Admin session
- **Response:** Full page (`pages/exercise_browser.html`)
- **Template context:** `exercises`, `workout_days`, `muscle_groups`, `admin`
### `GET /exercises/search`
HTMX partial — return filtered exercise list.
- **Auth:** Admin session
- **Query params:**
- `workout_day` (string, optional) — filter by workout day name
- `muscle_group` (string, optional) — filter by muscle group
- **Response:** HTMX partial (`partials/exercise_list.html`)
- **Usage:** Called via `hx-get` from exercise browser filter dropdowns
---
## Workout Logging
All logging routes require admin authentication. Responses are HTMX partials that update inline.
### `POST /log`
Log a single set for an exercise. Auto-creates today's workout session if needed.
- **Auth:** Admin session
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `exercise_id` (int)
- `workout_day_id` (int)
- `set_number` (int, default=1)
- `reps` (int)
- `weight` (string) — e.g., "30 lbs", "BW"
- `felt_easy` (checkbox, "on" = true)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs for this exercise
- **Error:** If no active profile selected, returns `partials/flash_message.html` with error
### `POST /log/{log_id}/edit`
Edit an existing log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Content-Type:** `application/x-www-form-urlencoded`
- **Form fields:**
- `reps` (int)
- `weight` (string)
- `felt_easy` (checkbox)
- `notes` (string, optional)
- **Response:** HTMX partial (`partials/log_entry.html`) with updated logs
### `POST /log/{log_id}/delete`
Delete a log entry.
- **Auth:** Admin session
- **Path params:** `log_id` (int)
- **Response:** HTMX partial (`partials/log_entry.html`) with remaining logs, or empty HTML if log not found
---
## History
All history routes require admin authentication.
### `GET /history`
Display log history for the active profile — list of past sessions, most recent first.
- **Auth:** Admin session
- **Response:** Full page (`pages/log_history.html`)
- **Template context:** `sessions`, `days_by_id`, `active_profile`, `admin`
- **Notes:** Requires active profile to show sessions
### `GET /history/{session_id}`
Display detailed logs for a specific workout session, grouped by exercise.
- **Auth:** Admin session
- **Path params:** `session_id` (int)
- **Response:** Full page (`pages/session_detail.html`)
- **Template context:** `workout_session`, `logs_by_exercise`, `exercises_by_id`, `days_by_id`, `admin`
---
## Dashboard
All dashboard routes require admin authentication.
### `GET /dashboard`
Render the progress dashboard with summary stats, volume chart, and exercise links.
- **Auth:** Admin session
- **Response:** Full page (`pages/dashboard.html`)
- **Template context:** `stats`, `volume_data_json`, `exercises`, `active_profile`, `admin`
- **Notes:** Stats and volume data require an active profile. Chart.js renders client-side charts from JSON embedded in the template.
### `GET /dashboard/exercise/{exercise_id}`
Per-exercise progress page with charts and progression suggestions.
- **Auth:** Admin session
- **Path params:** `exercise_id` (int)
- **Response:** Full page (`pages/exercise_progress.html`)
- **Template context:** `exercise`, `progress_data_json`, `suggestion`, `active_profile`, `admin`
---
## Schedule
### `GET /schedule`
4-week calendar view showing which workout day maps to which date.
- **Auth:** Admin session
- **Response:** Full page (`pages/schedule.html`)
- **Template context:** `weeks`, `active_profile`, `admin`
- **Notes:** Calendar starts from Monday of the current week. Days with completed sessions are highlighted. Requires active profile for session completion data.
---
## Authentication Details
### Cookies
| Cookie | Purpose | Flags | TTL |
|--------|---------|-------|-----|
| `session` | Admin session token (itsdangerous signed) | httponly, samesite=lax | 24 hours |
| `active_profile_id` | Currently selected user profile ID | httponly, samesite=lax | 24 hours |
### Auth Failure Behavior
- Missing or invalid session cookie → 302 redirect to `/login`
- Handled via `NotAuthenticatedError` exception + registered handler in `app/main.py`

View File

@@ -1,6 +1,6 @@
# Documentation Folder
# Documentation
This folder contains business planning, architecture decisions, and documentation.
This folder contains architecture, API reference, and development documentation.
**No application code belongs here.**
@@ -8,15 +8,16 @@ This folder contains business planning, architecture decisions, and documentatio
| Document | Purpose |
|----------|---------|
| `code_guidelines.md` | Code creation guidelines and Git strategy |
| `security.md` | Python focused security code suggestions |
| `roadmap.md` | 5-phase development roadmap |
| `plans/` | Design documents and implementation plans |
| `architecture.md` | App architecture, layers, patterns, request lifecycle |
| `API_REFERENCE.md` | All 23 endpoints with params, auth, and response details |
| `database_schema.md` | All 8 tables, columns, relationships, migrations |
| `code_guidelines.md` | Coding standards and Git strategy |
| `security.md` | Python-focused security guidance |
| `roadmap.md` | Feature status and future plans |
## Guidelines
- Keep documents focused and concise
- Update docs when architecture decisions change
- Use markdown tables for structured information
- Include links to external documentation where relevant
- Include links to external documentation where relevant

197
docs/architecture.md Normal file
View File

@@ -0,0 +1,197 @@
# Architecture
## Overview
SneakySwole is a workout tracking and programming app built with FastAPI, HTMX, and SQLite. It follows a layered architecture with strict separation of concerns: routes handle HTTP, services handle business logic and data access, and models define the schema.
All user interactions return HTML (full pages or HTMX partials) — there are no JSON APIs except the health check.
---
## Technology Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Web framework | FastAPI + Uvicorn | Async, Python 3.12 |
| Frontend | Jinja2 + HTMX + Pico CSS | Dark theme, no JS framework |
| Database | SQLite3 + SQLModel ORM | Single file at `data/sneakyswole.db` |
| Migrations | Alembic | Schema versioning, auto-generated DDL |
| Auth | bcrypt + itsdangerous | Hashed passwords, signed session cookies |
| Logging | structlog | Structured JSON logging |
| Config | pydantic-settings | Typed `.env` loader with validation |
| Container | Docker + docker-compose | Single service, port 8000, named volume |
| Testing | pytest | 30+ test modules |
| Dependencies | uv + requirements.txt | Pinned versions |
---
## Application Layers
### 1. Routes (`app/routes/`)
HTTP handlers that parse requests, call services, and return rendered templates. Each file covers one domain.
| File | Prefix | Purpose |
|------|--------|---------|
| `auth.py` | `/login`, `/logout` | Login form, credential verification, session cookies |
| `pages.py` | `/` | Home page |
| `profiles.py` | `/profiles` | Profile list, switch, edit |
| `workouts.py` | `/workouts` | Workout day list and detail viewer |
| `exercises.py` | `/exercises` | Exercise browser with HTMX search |
| `logging.py` | `/log` | Inline set logging (create/edit/delete) |
| `history.py` | `/history` | Past session list and detail |
| `dashboard.py` | `/dashboard` | Progress stats, volume charts, per-exercise progress |
| `schedule.py` | `/schedule` | 4-week calendar view |
| `health.py` | `/health` | JSON health check |
**Rules:**
- Routes never query the database directly — all data access goes through services
- All routes (except `/login`, `/logout`, `/health`) require admin authentication via `@Depends(get_current_admin_user)`
- Routes return `TemplateResponse` (HTML), not JSON
### 2. Services (`app/services/`)
Business logic and all database access. Each service is instantiated with a SQLModel `Session`.
| Service | File | Responsibility |
|---------|------|---------------|
| `AuthService` | `auth_service.py` | bcrypt password verification, session token creation/validation (itsdangerous) |
| `UserService` | `user_service.py` | User CRUD, list profiles, update stats |
| `ExerciseService` | `exercise_service.py` | Exercise queries (by day, muscle group), warmup listing, workout day listing |
| `LogService` | `log_service.py` | Workout log CRUD (create/read/update/delete sets) |
| `WorkoutSessionService` | `workout_session_service.py` | Session management (get_or_create, list, lookup) |
| `ProgressionService` | `progression_service.py` | Auto-progression suggestions (+reps/+weight/deload) |
| `AnalyticsService` | `analytics_service.py` | User stats, volume by day, per-exercise progress data |
| `SeedService` | `seed_service.py` | YAML-driven database initialization from `config/` files |
### 3. Models (`app/models/`)
SQLModel ORM classes defining 8 database tables. See `docs/database_schema.md` for full details.
### 4. Utils (`app/utils/`)
Shared utilities — currently just auth dependencies:
- **`auth.py`** — `get_current_admin_user()` (FastAPI dependency), `get_active_profile_id()`, `NotAuthenticatedError`, `SESSION_COOKIE_NAME`
### 5. Templates (`app/templates/`)
Jinja2 templates split into full pages and reusable HTMX partials.
- **`base.html`** — Master layout with Pico CSS dark theme, HTMX script, nav bar
- **`pages/`** — 12 full-page templates (login, home, dashboard, workout_day, etc.)
- **`partials/`** — 13 HTMX fragment templates (exercise_card, log_form, nav, stats_card, etc.)
### 6. Configuration
- **`app/config.py`** — Typed `Settings` class (pydantic-settings), singleton via `@lru_cache`
- **`.env`** — Runtime secrets (gitignored), referenced by docker-compose
- **`.env.example`** — Template documenting required variables
- **`config/exercises.yaml`** — Exercise library (name, muscle group, sets, tempo, form cues)
- **`config/user_programs.yaml`** — Per-user programming targets (week 1/4 reps and weights)
---
## Key Patterns
### Authentication Flow
1. Admin submits credentials via `POST /login`
2. `AuthService.authenticate()` verifies bcrypt hash
3. On success, `AuthService.create_session_token()` creates a signed token (itsdangerous)
4. Token stored in httponly cookie (`session`), samesite=lax, 24h TTL
5. `get_current_admin_user()` dependency validates token on every protected route
6. Invalid/missing token raises `NotAuthenticatedError` → 302 redirect to `/login`
### Profile Switching
- Admin selects active profile via `POST /profiles/switch`
- Profile ID stored in separate httponly cookie (`active_profile_id`)
- `get_active_profile_id()` extracts it from the request
- Workout logging happens under the active profile
### NavContextMiddleware
Starlette middleware (`app/main.py:NavContextMiddleware`) runs on every request:
1. Reads session cookie and validates token
2. If valid admin: loads `admin`, `profiles` list, and `active_profile` into `request.state`
3. Templates read from `request.state` to render the nav bar (profile switcher, etc.)
### HTMX Partial Pattern
All dynamic updates use HTMX with HTML fragment responses:
- Filter dropdowns trigger `hx-get` to `/exercises/search` → returns `partials/exercise_list.html`
- Log form submits `hx-post` to `/log` → returns `partials/log_entry.html`
- No JSON APIs, no fetch calls, no vanilla JS
### Database Startup
1. `create_app()` creates SQLModel engine and calls `SQLModel.metadata.create_all()`
2. `@app.on_event("startup")` triggers `SeedService.seed_all()`
3. Seed service reads `config/exercises.yaml` + `config/user_programs.yaml`
4. Inserts exercises, warmups, workout days, user profiles, and programming targets
5. Creates admin user from `.env` credentials (bcrypt-hashed)
6. Skips seeding if data already exists
---
## Request Lifecycle
```
Client Request
NavContextMiddleware (injects admin/profiles/active_profile into request.state)
FastAPI Router (matches route)
Auth Dependency (get_current_admin_user — validates session cookie)
Route Handler (parses request, calls service(s))
Service Layer (business logic, DB queries via SQLModel Session)
Jinja2 TemplateResponse (renders full page or HTMX partial)
Client Response (HTML)
```
---
## Docker Setup
- **`Dockerfile`** — Slim Python 3.12 base, copies app + config, installs deps, runs Uvicorn
- **`docker-compose.yaml`** — Single `app` service, port 8000, named volume for `data/`, `.env` file
- Static files and templates are baked into the image
- SQLite DB persists via Docker volume mount at `data/`
---
## Directory Structure
```
SneakySwole/
├── app/
│ ├── __init__.py
│ ├── config.py # Typed settings (pydantic-settings)
│ ├── database.py # Engine factory + session dependency
│ ├── logging_config.py # structlog setup
│ ├── main.py # App factory, middleware, router registration
│ ├── models/ # SQLModel ORM (8 tables)
│ ├── routes/ # FastAPI handlers (10 modules)
│ ├── services/ # Business logic + DB access (8 services)
│ ├── static/css/ # Pico CSS overrides
│ ├── templates/ # Jinja2 (pages/ + partials/)
│ └── utils/ # Auth dependencies
├── config/ # YAML seed data
│ ├── exercises.yaml
│ └── user_programs.yaml
├── alembic/ # Database migrations
├── tests/ # pytest test suite
├── data/ # SQLite DB (gitignored, Docker volume)
├── docs/ # Documentation
├── Dockerfile
├── docker-compose.yaml
├── pyproject.toml
├── requirements.txt
└── .env.example
```

202
docs/database_schema.md Normal file
View File

@@ -0,0 +1,202 @@
# Database Schema
SQLite database at `data/sneakyswole.db`, managed by SQLModel ORM with Alembic migrations.
---
## Tables
### `users`
User profiles (admin and regular). Admin has login credentials; regular profiles are managed by the admin.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `username` | VARCHAR | UNIQUE, indexed | Login identifier |
| `password_hash` | VARCHAR | default="" | bcrypt hash (admin only) |
| `display_name` | VARCHAR | default="" | Name shown in UI |
| `height` | VARCHAR | nullable | e.g., "6'0\"" |
| `weight` | VARCHAR | nullable | e.g., "260 lbs" |
| `goals` | VARCHAR | nullable | Free-text training goals |
| `is_admin` | BOOLEAN | default=False | Admin privileges flag |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user.py:User`
---
### `exercises`
Exercise library catalog. Each exercise belongs to a workout day.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "DB Chest Press (Floor)" |
| `muscle_group` | VARCHAR | default="" | e.g., "Chest", "Shoulders" |
| `workout_day` | VARCHAR | indexed | "Push", "Pull", "Lower", "Full Body" |
| `sets` | INTEGER | default=3 | Default number of sets |
| `tempo` | VARCHAR | default="" | e.g., "3-1-2" (eccentric-pause-concentric) |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/exercise.py:Exercise`
---
### `warmups`
Standardized warmup routine displayed before every workout.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | indexed | e.g., "Cat / Cow" |
| `type` | VARCHAR | default="" | Category: "Thoracic Mob", "Hip Mobility", etc. |
| `reps` | VARCHAR | default="" | e.g., "8 reps", "8 each side" |
| `form_cues` | VARCHAR | default="" | Detailed form instructions |
| `sort_order` | INTEGER | default=0 | Display order in warmup sequence |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/warmup.py:Warmup`
---
### `workout_days`
The 4-day training split definition.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `name` | VARCHAR | UNIQUE, indexed | "Push", "Pull", "Lower", "Full Body" |
| `day_number` | INTEGER | UNIQUE | Order in rotation (1-4) |
| `description` | VARCHAR | default="" | Brief focus description |
**Model:** `app/models/workout_day.py:WorkoutDay`
---
### `user_exercise_programs`
Per-user programming targets linking users to exercises with week 1/4 goals.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile this applies to |
| `exercise_id` | INTEGER | FK → `exercises.id`, indexed | Exercise being programmed |
| `wk1_reps` | VARCHAR | default="" | Week 1 target reps (e.g., "10", "30 sec") |
| `wk4_reps` | VARCHAR | default="" | Week 4 target reps |
| `wk1_weight` | VARCHAR | default="" | Week 1 target weight (e.g., "30 lbs", "BW") |
| `wk4_weight` | VARCHAR | default="" | Week 4 target weight |
| `created_at` | DATETIME | auto | Record creation time |
| `updated_at` | DATETIME | auto | Last update time |
**Model:** `app/models/user_exercise_program.py:UserExerciseProgram`
---
### `workout_sessions`
A completed workout session — ties a user to a workout day on a date.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Who did the workout |
| `workout_day_id` | INTEGER | FK → `workout_days.id` | Which day was trained |
| `date` | DATE | default=today | Date the workout was performed |
| `notes` | VARCHAR | nullable | Free-text session notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_session.py:WorkoutSession`
---
### `workout_logs`
Individual set logs within a session. Each row = one set of one exercise.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `session_id` | INTEGER | FK → `workout_sessions.id`, indexed | Parent session |
| `exercise_id` | INTEGER | FK → `exercises.id` | Which exercise |
| `set_number` | INTEGER | default=1 | Set number (1, 2, 3...) |
| `reps_completed` | INTEGER | default=0 | Actual reps performed |
| `weight_used` | VARCHAR | default="" | Weight used (e.g., "30 lbs", "BW") |
| `felt_easy` | BOOLEAN | default=False | Progression signal |
| `notes` | VARCHAR | nullable | Per-set notes |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/workout_log.py:WorkoutLog`
---
### `progress_log`
Progression tracking — what the engine suggested vs what the user actually did.
| Column | Type | Constraints | Description |
|--------|------|------------|-------------|
| `id` | INTEGER | PK, auto-increment | |
| `user_id` | INTEGER | FK → `users.id`, indexed | Profile being tracked |
| `exercise_id` | INTEGER | FK → `exercises.id` | Exercise being tracked |
| `date` | DATE | default=today | Date of progression entry |
| `suggested_reps` | INTEGER | nullable | Engine recommendation |
| `suggested_weight` | VARCHAR | nullable | Engine recommendation |
| `actual_reps` | INTEGER | nullable | What user actually did |
| `actual_weight` | VARCHAR | nullable | What user actually used |
| `progression_applied` | VARCHAR | nullable | Type: "reps_increase", "weight_increase", "deload" |
| `created_at` | DATETIME | auto | Record creation time |
**Model:** `app/models/progress_log.py:ProgressLog`
---
## Relationships
```
users
├── user_exercise_programs (1:N via user_id)
├── workout_sessions (1:N via user_id)
└── progress_log (1:N via user_id)
exercises
├── user_exercise_programs (1:N via exercise_id)
├── workout_logs (1:N via exercise_id)
└── progress_log (1:N via exercise_id)
workout_days
└── workout_sessions (1:N via workout_day_id)
workout_sessions
└── workout_logs (1:N via session_id)
```
---
## Migrations
Managed by Alembic. Config at `alembic.ini`, migration scripts at `alembic/versions/`.
- **Initial migration:** `1855836abf6c_initial_schema_8_tables.py` — creates all 8 tables
To create a new migration:
```bash
alembic revision --autogenerate -m "description"
alembic upgrade head
```
---
## Seeding
On first startup, `SeedService.seed_all()` reads:
- `config/exercises.yaml` — exercise catalog + warmups + workout days
- `config/user_programs.yaml` — per-user week 1/4 targets
Admin user is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars with bcrypt hash. Seeding is skipped if data already exists.

View File

@@ -1,76 +0,0 @@
# Design: Roadmap, Exercise Data, and Auth Model
**Date:** 2026-02-23
**Status:** Approved
## Overview
This document captures the design decisions for the SneakySwole roadmap structure, exercise data management, and authentication model.
## Roadmap Structure
Five phases, each producing a testable milestone:
1. **Scaffold & Infrastructure** — project structure, Docker, base template, logging
2. **Data Layer & Seeding** — SQLite schema, Alembic migrations, YAML-based exercise/program seeding
3. **Workout UI** — admin auth, profile management, workout viewer, exercise browser
4. **Logging & Tracking** — per-exercise logging, session tracking, log history
5. **Progression & Analytics** — auto-progression, schedule view, progress dashboard
Full details in `docs/roadmap.md`.
## Exercise Data Design
### Separated YAML Files
Exercise data is split into two config files for clean separation of concerns:
- **`config/exercises.yaml`** — Universal exercise library
- Exercise name, muscle group, workout day, sets, tempo, form cues
- Warmup exercises (name, type, reps/time, form cues)
- Rarely changes; defines the available exercise catalog
- **`config/user_programs.yaml`** — Per-user programming
- References exercises by name
- Week 1 and week 4 reps and weights per user per exercise
- Changes when users adjust their programming or new users are added
### Seeding Flow
1. On first run (empty DB), the seed script reads both YAML files
2. Exercises and warmups are inserted into their respective tables
3. User profiles are created (admin from `.env`, others from user_programs.yaml)
4. User-specific programming (reps/weights) is linked to exercises and users
5. Subsequent runs skip seeding if data already exists
### Why YAML Over Spreadsheet
- Human-readable and version-controllable
- Easy to update without special tools
- Can be validated with schema checks
- Spreadsheet remains source of truth for initial data extraction only
## Authentication Model
### Phase 1: Simple Admin Auth
- Single admin user with credentials stored in `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- On first run, admin user is created in DB with bcrypt-hashed password
- Admin logs in via a simple login form (session-based auth)
- Admin creates user profiles through the UI (no login for profiles)
- Profile switcher in navigation allows admin to select the active profile
- All workout logging happens under the selected profile
### Security Considerations
- Passwords hashed with bcrypt (never stored plaintext)
- `.env` file is gitignored and never committed
- `.env.example` documents required variables without real values
- Session tokens with reasonable expiry
- Admin-only routes protected by auth middleware
### Future Upgrade Path
- Phase 1 auth is designed to be replaceable
- If multi-user login is needed later, add login credentials to the user profiles table
- Profile switcher becomes unnecessary when each user logs in independently

View File

@@ -1,59 +1,12 @@
# SneakySwole Roadmap
## Phase 1: Scaffold & Infrastructure
## V3 Improvements
Set up the project foundation — everything needed before writing features.
Each improvement is implemented as a separate branch/PR. Implementation order matters — auth removal goes first since other features depend on the simplified profile flow.
- FastAPI project structure (`app/` with routes, services, models, templates, static, utils)
- Dockerfile + docker-compose.yaml (SQLite volume, port 8000, `.env` support)
- Pico CSS dark theme base template (Jinja2 with `data-theme="dark"`)
- `.env` / `.env.example` with admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`)
- `uv` for dependency management, `requirements.txt` with pinned versions
- Structlog logging setup
- Basic health check route (`/health`)
## Phase 2: Data Layer & Seeding
---
Build the database schema and seed it from YAML config files.
- SQLite schema via Alembic migrations
- Tables: users, exercises, warmups, workout_days, user_exercise_programs, sets, progress_log
- `config/exercises.yaml` — exercise library (name, muscle group, workout day, sets, tempo, form cues)
- `config/user_programs.yaml` — per-user week 1/4 reps and weights for each exercise
- Seed script that loads YAML into DB on first run
- Admin user auto-created from `.env` credentials on startup (password hashed with bcrypt)
- Two initial user profiles seeded: Phillip and Daughter
- Service layer for all DB access (no direct queries from routes)
## Phase 3: Workout UI
The core user-facing experience — viewing workouts and managing profiles.
- Admin login (simple session-based auth, password hashed)
- Profile switcher in nav (admin selects active user profile)
- Admin can create and edit user profiles (name, weight, height, goals)
- Workout day viewer — warmup routine + main exercises with full form cues
- Exercise library browser (search/filter by muscle group, workout day)
- All interactions via HTMX partials (no JSON APIs, no vanilla JS)
## Phase 4: Logging & Tracking
Enable workout logging so users can track what they actually did.
- Per-exercise logging: sets completed, reps, weight used, "felt easy?" toggle
- Workout session model (date, user profile, workout day)
- HTMX inline logging — log directly from the workout day view
- Log history view per user profile
- Edit/delete past log entries
## Phase 5: Progression & Analytics
Smart suggestions and visual progress tracking.
- Auto-progression engine based on log history
- +1-2 reps/week, +5 lbs every 2 weeks
- Deload detection at week 5 (-20% weight)
- 4-week schedule view (calendar-style, shows which day maps to which date)
- Progress dashboard per user profile
- Per-exercise progress history and trends
- Summary stats (total volume, streak tracking)
## Future Ideas
- Exercise video/image attachments
- Custom workout program builder

12
pyproject.toml Normal file
View File

@@ -0,0 +1,12 @@
[project]
name = "sneakyswole"
version = "0.1.0"
description = "Open-source workout tracking and programming app"
requires-python = ">=3.12"
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi>=0.115.0,<1.0.0
uvicorn[standard]>=0.34.0,<1.0.0
jinja2>=3.1.0,<4.0.0
structlog>=24.0.0,<25.0.0
python-dotenv>=1.0.0,<2.0.0
python-multipart>=0.0.20,<1.0.0
pyyaml>=6.0.0,<7.0.0
sqlmodel>=0.0.22,<1.0.0
alembic>=1.14.0,<2.0.0
httpx>=0.28.0,<1.0.0
pytest>=8.0.0,<9.0.0
pytest-asyncio>=0.24.0,<1.0.0
pydantic-settings>=2.7.0,<3.0.0

2
run_dev_docker.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
docker compose -f docker-compose.dev.yaml up --build "$@"

0
tests/__init__.py Normal file
View File

31
tests/conftest.py Normal file
View File

@@ -0,0 +1,31 @@
"""Shared test fixtures for the SneakySwole test suite."""
import os
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
def _set_test_env() -> None:
"""Ensure required env vars are set for all tests."""
with patch.dict(os.environ, {
"APP_ENV": "development",
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
}, clear=False):
yield
@pytest.fixture
def client() -> TestClient:
"""Create a FastAPI TestClient for integration tests."""
from app.main import create_app
app = create_app()
return TestClient(app)
def set_profile_cookie(client: TestClient, profile_id: int) -> None:
"""Helper to set the active_profile_id cookie on a test client."""
client.cookies.set("active_profile_id", str(profile_id))

View File

@@ -0,0 +1,96 @@
"""Tests for the AnalyticsService class."""
from datetime import date, timedelta
from sqlmodel import SQLModel, Session, create_engine
from app.models.user import User
from app.models.exercise import Exercise
from app.models.workout_day import WorkoutDay
from app.models.workout_session import WorkoutSession
from app.models.workout_log import WorkoutLog
from app.services.analytics_service import AnalyticsService
class TestAnalyticsService:
"""Tests for analytics data aggregation."""
def _setup(self):
"""Create DB with sessions and logs for analytics."""
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
session = Session(engine)
user = User(username="phil", display_name="Phillip")
day = WorkoutDay(name="Push", day_number=1, description="Push day")
exercise = Exercise(
name="DB Chest Press", muscle_group="Chest",
workout_day="Push", sets=3, tempo="3-1-2", form_cues="...",
)
session.add_all([user, day, exercise])
session.commit()
session.refresh(user)
session.refresh(day)
session.refresh(exercise)
# Create 3 sessions over 3 weeks
for week in range(3):
ws = WorkoutSession(
user_id=user.id, workout_day_id=day.id,
date=date.today() - timedelta(days=(2 - week) * 7),
)
session.add(ws)
session.commit()
session.refresh(ws)
for set_num in range(1, 4):
session.add(WorkoutLog(
session_id=ws.id, exercise_id=exercise.id,
set_number=set_num,
reps_completed=8 + week,
weight_used="30 lbs",
felt_easy=(week == 2),
))
session.commit()
service = AnalyticsService(session)
return session, service, user, exercise
def test_get_total_sessions(self) -> None:
"""Should return the total number of sessions for a user."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_sessions"] == 3
session.close()
def test_get_total_volume(self) -> None:
"""Should calculate total volume (sets x reps x weight)."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["total_volume"] > 0
session.close()
def test_get_workout_streak(self) -> None:
"""Should calculate consecutive workout weeks."""
session, service, user, exercise = self._setup()
stats = service.get_user_stats(user.id)
assert stats["current_streak"] >= 1
session.close()
def test_get_exercise_progress_data(self) -> None:
"""Should return chart-ready data for an exercise."""
session, service, user, exercise = self._setup()
data = service.get_exercise_progress(user.id, exercise.id)
assert "dates" in data
assert "reps" in data
assert "weights" in data
assert len(data["dates"]) == 3
session.close()
def test_get_volume_by_day(self) -> None:
"""Should return volume per workout day for charts."""
session, service, user, exercise = self._setup()
data = service.get_volume_by_day(user.id)
assert "Push" in data
assert data["Push"] > 0
session.close()

12
tests/test_app.py Normal file
View File

@@ -0,0 +1,12 @@
"""Tests for the FastAPI application factory."""
from fastapi.testclient import TestClient
class TestCreateApp:
"""Tests for the application factory and basic setup."""
def test_app_starts_without_error(self, client: TestClient) -> None:
"""The app should initialize and serve requests."""
response = client.get("/health")
assert response.status_code == 200

27
tests/test_config.py Normal file
View File

@@ -0,0 +1,27 @@
"""Tests for the application configuration loader."""
import os
from unittest.mock import patch
from app.config import Settings, get_settings
class TestSettings:
"""Tests for the Settings configuration class."""
def test_settings_loads_defaults(self) -> None:
"""Settings should have sensible defaults for all fields."""
# Remove DATABASE_URL so we test the actual default
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
with patch.dict(os.environ, env_clean, clear=True):
settings = Settings()
assert settings.app_env == "development"
assert settings.app_host == "0.0.0.0"
assert settings.app_port == 8000
assert settings.database_url == "sqlite:///data/sneakyswole.db"
def test_get_settings_returns_singleton(self) -> None:
"""get_settings should return the same instance on repeated calls."""
s1 = get_settings()
s2 = get_settings()
assert s1 is s2

View File

@@ -0,0 +1,23 @@
"""Tests for dashboard routes."""
from fastapi.testclient import TestClient
class TestDashboard:
"""Tests for GET /dashboard."""
def test_dashboard_requires_profile(self, client: TestClient) -> None:
"""GET /dashboard should redirect to / without profile cookie."""
response = client.get("/dashboard", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/"
class TestExerciseProgress:
"""Tests for GET /dashboard/exercise/<id>."""
def test_exercise_progress_requires_profile(self, client: TestClient) -> None:
"""GET /dashboard/exercise/1 should redirect to / without profile cookie."""
response = client.get("/dashboard/exercise/1", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/"

26
tests/test_database.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for database engine and session management."""
from sqlmodel import Session
from app.database import get_engine, get_db_session
class TestDatabase:
"""Tests for database connection management."""
def test_get_engine_returns_engine(self) -> None:
"""get_engine should return a SQLAlchemy engine instance."""
engine = get_engine("sqlite:///data/test.db")
assert engine is not None
assert "sqlite" in str(engine.url)
def test_get_db_session_yields_session(self) -> None:
"""get_db_session should yield a usable SQLModel Session."""
engine = get_engine("sqlite:///:memory:")
session_gen = get_db_session(engine)
session = next(session_gen)
assert isinstance(session, Session)
try:
next(session_gen)
except StopIteration:
pass

Some files were not shown because too many files have changed in this diff Show More