5 Commits

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:44:21 -05:00
14 changed files with 828 additions and 10 deletions

3
Caddyfile Normal file
View File

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

View File

@@ -7,12 +7,14 @@ 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, Query, Request
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlmodel import Session
@@ -21,6 +23,7 @@ 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
@@ -39,6 +42,11 @@ async def dashboard(
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()
@@ -52,6 +60,11 @@ async def dashboard(
"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,
@@ -100,6 +113,36 @@ async def export_csv(
)
@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,

View File

@@ -9,6 +9,8 @@ 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
@@ -179,3 +181,197 @@ class AnalyticsService:
)
return volume_by_day
def get_personal_records(self, user_id: int) -> list[dict]:
"""Get per-exercise max weight records for a user.
Returns:
List of dicts with exercise_name, weight, weight_display, date.
Sorted by weight descending. BW-only exercises excluded.
"""
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
).all()
# Map exercise_id -> {max_weight, weight_str, date, exercise_name}
records: dict[int, dict] = {}
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
for log_entry in logs:
weight = _weight_to_float(log_entry.weight_used)
if weight == 0.0:
continue
existing = records.get(log_entry.exercise_id)
if existing is None or weight > existing["weight"]:
records[log_entry.exercise_id] = {
"exercise_id": log_entry.exercise_id,
"weight": weight,
"weight_display": log_entry.weight_used,
"date": ws.date,
}
# Resolve exercise names
result = []
for exercise_id, rec in records.items():
exercise = self._session.get(Exercise, exercise_id)
if exercise:
rec["exercise_name"] = exercise.name
result.append(rec)
result.sort(key=lambda r: r["weight"], reverse=True)
return result
def get_adherence_rate(self, user_id: int, weeks: int = 8) -> dict:
"""Calculate workout adherence rate over the past N weeks.
Returns:
Dict with rate (0-100), completed, expected, weeks.
"""
cutoff = date.today() - timedelta(weeks=weeks)
sessions = self._session.exec(
select(WorkoutSession)
.where(
WorkoutSession.user_id == user_id,
WorkoutSession.date >= cutoff,
)
).all()
# Only count sessions with logs
completed = 0
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if logs:
completed += 1
expected = weeks * 4
rate = round((completed / expected) * 100) if expected > 0 else 0
return {
"rate": min(rate, 100),
"completed": completed,
"expected": expected,
"weeks": weeks,
}
def get_muscle_group_recency(self, user_id: int) -> list[dict]:
"""Get the most recent workout date for each muscle group.
Returns:
List of dicts with muscle_group, last_worked, days_ago.
Sorted by days_ago descending (most stale first).
"""
exercises = self._session.exec(select(Exercise)).all()
muscle_groups = {e.muscle_group for e in exercises if e.muscle_group}
# Map exercise_id -> muscle_group for fast lookup
ex_muscle = {e.id: e.muscle_group for e in exercises}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
recency: dict[str, date] = {}
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
for log_entry in logs:
mg = ex_muscle.get(log_entry.exercise_id, "")
if mg and (mg not in recency or ws.date > recency[mg]):
recency[mg] = ws.date
today = date.today()
result = []
for mg in sorted(muscle_groups):
last_worked = recency.get(mg)
days_ago = (today - last_worked).days if last_worked else None
result.append({
"muscle_group": mg,
"last_worked": last_worked,
"days_ago": days_ago,
})
# Sort: never-worked first, then most stale
result.sort(
key=lambda r: (r["days_ago"] is None, -(r["days_ago"] or 0)),
reverse=True,
)
return result
def get_recent_activity(self, user_id: int, limit: int = 5) -> list[dict]:
"""Get the last N workout sessions with summary data.
Returns:
List of dicts with date, workout_day_name, total_volume, total_sets.
"""
days = self._session.exec(select(WorkoutDay)).all()
day_map = {d.id: d.name for d in days}
sessions = self._session.exec(
select(WorkoutSession)
.where(WorkoutSession.user_id == user_id)
.order_by(WorkoutSession.date.desc())
).all()
result = []
for ws in sessions:
logs = self._session.exec(
select(WorkoutLog).where(WorkoutLog.session_id == ws.id)
).all()
if not logs:
continue
total_volume = sum(
log_entry.reps_completed * _weight_to_float(log_entry.weight_used)
for log_entry in logs
)
result.append({
"date": ws.date,
"workout_day_name": day_map.get(ws.workout_day_id, "Unknown"),
"total_volume": round(total_volume),
"total_sets": len(logs),
})
if len(result) >= limit:
break
return result
def get_progression_timeline(self, user_id: int) -> dict:
"""Get progression history for Chart.js multi-line chart.
Returns:
Dict with 'exercises' key mapping exercise names to
{dates, weights, events} lists.
"""
logs = self._session.exec(
select(ProgressLog)
.where(ProgressLog.user_id == user_id)
.order_by(ProgressLog.date.asc())
).all()
exercises: dict[int, list] = {}
for pl in logs:
exercises.setdefault(pl.exercise_id, []).append(pl)
result = {}
for exercise_id, entries in exercises.items():
exercise = self._session.get(Exercise, exercise_id)
if not exercise:
continue
name = exercise.name
result[name] = {
"dates": [e.date.isoformat() for e in entries],
"weights": [
_weight_to_float(e.actual_weight or e.suggested_weight or "0")
for e in entries
],
"events": [e.progression_applied or "" for e in entries],
}
return {"exercises": result}

View File

@@ -0,0 +1,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

View File

@@ -34,3 +34,36 @@ main.container {
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);
}

View File

@@ -4,6 +4,7 @@
{% 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 %}
@@ -28,6 +29,15 @@
{% 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>
@@ -45,5 +55,9 @@
<!-- Export -->
{% include "partials/export_form.html" %}
{% endif %}
<!-- Import -->
{% include "partials/import_form.html" %}
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,8 @@
</details>
</li>
<li><a href="/">Home</a></li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/workouts">Workouts</a></li>
<li><a href="/history">History</a></li>
<li><a href="/exercises">Exercises</a></li>
<li><a href="/profiles">Profiles</a></li>

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,29 @@
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
ports:
- "${APP_PORT:-8000}:8000"
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')"]
@@ -16,6 +32,12 @@ services:
retries: 3
start_period: 10s
networks:
sneakyswole:
driver: bridge
volumes:
sneakyswole-data:
driver: local
caddy-data:
driver: local