Compare commits
51 Commits
3f7ce965e1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 059ba8f778 | |||
| 4e6930c207 | |||
| cea1b4e80e | |||
| bae0bc9dee | |||
| df8d5c65fb | |||
| c5a7728818 | |||
| ebecfd0b58 | |||
| 0389aef56e | |||
| 52e48f8ed4 | |||
| 69b3357800 | |||
| 4b117c6fa7 | |||
| 931e452205 | |||
| 5b26f36f5d | |||
| 758034b25a | |||
| 576d3bbb68 | |||
| 91b3d24147 | |||
| 2208f0492b | |||
| 60acdbefdb | |||
| 7b535bef6e | |||
| 272563060c | |||
| 3dc0171639 | |||
| 312b14e57b | |||
| d8b52cf907 | |||
| b18146e96c | |||
| ee45513f30 | |||
| d90c9faf23 | |||
| 093f7aa55e | |||
| 77d1bc4a25 | |||
| 89d70fcae7 | |||
| 53e62f694f | |||
| fd979aa2da | |||
| 215ce90404 | |||
| 134542b66f | |||
| e35b78ae87 | |||
| 23754ea239 | |||
| 1f47103480 | |||
| 7c07bcee25 | |||
| 6f9e923846 | |||
| 42f6667b23 | |||
| afb2cdf308 | |||
| e6ee17d1ff | |||
| 3bf1e13adc | |||
| 45b93a2988 | |||
| f9ff42c665 | |||
| 835ab197eb | |||
| df7e86f2ed | |||
| b3b34222c8 | |||
| fb4b948681 | |||
| a211f9b6bd | |||
| 2a9891ce69 | |||
| 4afc7c35a6 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
11
.env.example
Normal 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
9
.env.production
Normal 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
|
||||
46
.gitea/workflows/build-package.yaml
Normal file
46
.gitea/workflows/build-package.yaml
Normal 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
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
149
alembic.ini
Normal 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
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
57
alembic/env.py
Normal file
57
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
||||
155
alembic/versions/1855836abf6c_initial_schema_8_tables.py
Normal file
155
alembic/versions/1855836abf6c_initial_schema_8_tables.py
Normal 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 ###
|
||||
37
alembic/versions/a1b2c3d4e5f6_remove_auth_columns.py
Normal file
37
alembic/versions/a1b2c3d4e5f6_remove_auth_columns.py
Normal 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'))
|
||||
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal file
45
alembic/versions/b2c3d4e5f6g7_simplify_to_starting_weight.py
Normal 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
0
app/__init__.py
Normal file
50
app/config.py
Normal file
50
app/config.py
Normal 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
60
app/database.py
Normal 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
48
app/logging_config.py
Normal 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
176
app/main.py
Normal 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
24
app/models/__init__.py
Normal 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
35
app/models/exercise.py
Normal 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)
|
||||
39
app/models/progress_log.py
Normal file
39
app/models/progress_log.py
Normal 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
35
app/models/user.py
Normal 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)
|
||||
31
app/models/user_exercise_program.py
Normal file
31
app/models/user_exercise_program.py
Normal 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
33
app/models/warmup.py
Normal 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
26
app/models/workout_day.py
Normal 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
37
app/models/workout_log.py
Normal 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)
|
||||
31
app/models/workout_session.py
Normal file
31
app/models/workout_session.py
Normal 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
0
app/routes/__init__.py
Normal file
174
app/routes/dashboard.py
Normal file
174
app/routes/dashboard.py
Normal 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
63
app/routes/exercises.py
Normal 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
23
app/routes/health.py
Normal 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
81
app/routes/history.py
Normal 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
206
app/routes/logging.py
Normal 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
29
app/routes/pages.py
Normal 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
168
app/routes/profiles.py
Normal 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
138
app/routes/workouts.py
Normal 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
0
app/services/__init__.py
Normal file
377
app/services/analytics_service.py
Normal file
377
app/services/analytics_service.py
Normal 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}
|
||||
88
app/services/exercise_service.py
Normal file
88
app/services/exercise_service.py
Normal 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())
|
||||
80
app/services/export_service.py
Normal file
80
app/services/export_service.py
Normal 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
|
||||
281
app/services/import_service.py
Normal file
281
app/services/import_service.py
Normal 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
227
app/services/log_service.py
Normal 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())
|
||||
302
app/services/progression_service.py
Normal file
302
app/services/progression_service.py
Normal 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
|
||||
174
app/services/seed_service.py
Normal file
174
app/services/seed_service.py
Normal 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")
|
||||
99
app/services/user_service.py
Normal file
99
app/services/user_service.py
Normal 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
|
||||
143
app/services/workout_session_service.py
Normal file
143
app/services/workout_session_service.py
Normal 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
|
||||
69
app/static/css/sneakyswole.css
Normal file
69
app/static/css/sneakyswole.css
Normal 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
49
app/templates/base.html
Normal 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>
|
||||
63
app/templates/pages/dashboard.html
Normal file
63
app/templates/pages/dashboard.html
Normal 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 %}
|
||||
38
app/templates/pages/exercise_browser.html
Normal file
38
app/templates/pages/exercise_browser.html
Normal 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 %}
|
||||
48
app/templates/pages/exercise_progress.html
Normal file
48
app/templates/pages/exercise_progress.html
Normal 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 %}
|
||||
40
app/templates/pages/home.html
Normal file
40
app/templates/pages/home.html
Normal 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 %} · {% 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 %}
|
||||
23
app/templates/pages/log_history.html
Normal file
23
app/templates/pages/log_history.html
Normal 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 %}
|
||||
46
app/templates/pages/profile_create.html
Normal file
46
app/templates/pages/profile_create.html
Normal 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"">
|
||||
</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="/">← Back to profiles</a></p>
|
||||
{% endblock %}
|
||||
34
app/templates/pages/profile_edit.html
Normal file
34
app/templates/pages/profile_edit.html
Normal 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"">
|
||||
|
||||
<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 %}
|
||||
16
app/templates/pages/profiles.html
Normal file
16
app/templates/pages/profiles.html
Normal 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 %}
|
||||
51
app/templates/pages/session_detail.html
Normal file
51
app/templates/pages/session_detail.html
Normal 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 %}
|
||||
31
app/templates/pages/workout_day.html
Normal file
31
app/templates/pages/workout_day.html
Normal 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 %}
|
||||
38
app/templates/pages/workout_days.html
Normal file
38
app/templates/pages/workout_days.html
Normal 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 %}
|
||||
68
app/templates/partials/exercise_card.html
Normal file
68
app/templates/partials/exercise_card.html
Normal 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>
|
||||
16
app/templates/partials/exercise_list.html
Normal file
16
app/templates/partials/exercise_list.html
Normal 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 %}
|
||||
19
app/templates/partials/export_form.html
Normal file
19
app/templates/partials/export_form.html
Normal 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>
|
||||
|
||||
<button type="submit">Download CSV</button>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
6
app/templates/partials/flash_message.html
Normal file
6
app/templates/partials/flash_message.html
Normal 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 %}
|
||||
17
app/templates/partials/import_form.html
Normal file
17
app/templates/partials/import_form.html
Normal 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>
|
||||
|
||||
<button type="submit">Import</button>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div id="import-results"></div>
|
||||
</article>
|
||||
37
app/templates/partials/import_results.html
Normal file
37
app/templates/partials/import_results.html
Normal 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 %}
|
||||
39
app/templates/partials/log_entry.html
Normal file
39
app/templates/partials/log_entry.html
Normal 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" %}
|
||||
30
app/templates/partials/log_form.html
Normal file
30
app/templates/partials/log_form.html
Normal 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>
|
||||
24
app/templates/partials/muscle_heatmap.html
Normal file
24
app/templates/partials/muscle_heatmap.html
Normal 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>
|
||||
32
app/templates/partials/nav.html
Normal file
32
app/templates/partials/nav.html
Normal 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>
|
||||
12
app/templates/partials/profile_card.html
Normal file
12
app/templates/partials/profile_card.html
Normal 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>
|
||||
56
app/templates/partials/progress_chart.html
Normal file
56
app/templates/partials/progress_chart.html
Normal 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>
|
||||
20
app/templates/partials/progression_badge.html
Normal file
20
app/templates/partials/progression_badge.html
Normal 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 %}
|
||||
90
app/templates/partials/progression_chart.html
Normal file
90
app/templates/partials/progression_chart.html
Normal 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>
|
||||
29
app/templates/partials/recent_activity.html
Normal file
29
app/templates/partials/recent_activity.html
Normal 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>
|
||||
16
app/templates/partials/session_card.html
Normal file
16
app/templates/partials/session_card.html
Normal 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>
|
||||
53
app/templates/partials/stats_card.html
Normal file
53
app/templates/partials/stats_card.html
Normal 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>
|
||||
30
app/templates/partials/volume_chart.html
Normal file
30
app/templates/partials/volume_chart.html
Normal 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>
|
||||
23
app/templates/partials/warmup_list.html
Normal file
23
app/templates/partials/warmup_list.html
Normal 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
0
app/utils/__init__.py
Normal file
62
app/utils/auth.py
Normal file
62
app/utils/auth.py
Normal 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
|
||||
@@ -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
0
data/.gitkeep
Normal file
29
docker-compose.dev.yaml
Normal file
29
docker-compose.dev.yaml
Normal 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
43
docker-compose.yaml
Normal 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
278
docs/API_REFERENCE.md
Normal 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`
|
||||
@@ -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
197
docs/architecture.md
Normal 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
202
docs/database_schema.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
12
pyproject.toml
Normal 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
13
requirements.txt
Normal 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
2
run_dev_docker.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
docker compose -f docker-compose.dev.yaml up --build "$@"
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
31
tests/conftest.py
Normal file
31
tests/conftest.py
Normal 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))
|
||||
96
tests/test_analytics_service.py
Normal file
96
tests/test_analytics_service.py
Normal 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
12
tests/test_app.py
Normal 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
27
tests/test_config.py
Normal 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
|
||||
23
tests/test_dashboard_routes.py
Normal file
23
tests/test_dashboard_routes.py
Normal 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
26
tests/test_database.py
Normal 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
Reference in New Issue
Block a user