From 87c65e1edcf6d4cd69137d0c42a65bdbbf4c338e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Oct 2025 14:49:41 +0000 Subject: [PATCH] Add PR migration script and documentation Co-authored-by: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> --- .../app/activities/personal_records/README.md | 125 ++++++++++++++++++ .../activities/personal_records/migration.py | 95 +++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 backend/app/activities/personal_records/README.md create mode 100644 backend/app/activities/personal_records/migration.py diff --git a/backend/app/activities/personal_records/README.md b/backend/app/activities/personal_records/README.md new file mode 100644 index 000000000..97f1a1369 --- /dev/null +++ b/backend/app/activities/personal_records/README.md @@ -0,0 +1,125 @@ +# Personal Records (PRs) Module + +This module implements automatic tracking of personal records (PRs) for fitness activities. + +## Features + +### Supported Activity Types + +#### 🏃 Running (activity_type: 1, 2, 3, 34) +- **fastest_1km**: Fastest time to complete 1km +- **fastest_5km**: Fastest time to complete 5km +- **fastest_10km**: Fastest time to complete 10km +- **fastest_half_marathon**: Fastest time to complete 21.097km +- **fastest_marathon**: Fastest time to complete 42.195km +- **longest_distance**: Longest running distance +- **best_average_pace**: Best average pace (lower is better) + +#### 🚴 Cycling (activity_type: 4, 5, 6, 7, 27, 28, 29, 35, 36) +- **fastest_5km**: Fastest time to complete 5km +- **fastest_20km**: Fastest time to complete 20km +- **fastest_40km**: Fastest time to complete 40km +- **longest_distance**: Longest cycling distance +- **max_power**: Maximum power output (watts) +- **best_normalized_power**: Best normalized power (FTP indicator) + +#### 🏊 Swimming (activity_type: 8, 9) +- **fastest_50m**: Fastest time to complete 50m +- **fastest_100m**: Fastest time to complete 100m +- **fastest_200m**: Fastest time to complete 200m +- **fastest_400m**: Fastest time to complete 400m +- **fastest_1500m**: Fastest time to complete 1500m +- **longest_distance**: Longest swimming distance + +#### 🏋️ Strength Training (activity_type: 19, 20) +- Placeholder for future implementation (requires activity_sets parsing) + +## How It Works + +### Automatic PR Detection +PRs are automatically checked and updated whenever: +1. A new activity is uploaded (GPX, TCX, or FIT file) +2. An activity is synced from Strava +3. An activity is synced from Garmin Connect + +### Distance Tolerance +To ensure accurate PR tracking, distance-based PRs use a tolerance threshold: +- **Running/Cycling**: 2% tolerance (e.g., 4.9-5.1km counts as 5km) +- **Swimming**: 5% tolerance (pool distances can vary) + +This prevents false PRs from longer activities where we don't have split data. + +### Database Schema +```python +class PersonalRecord: + id: int # Primary key + user_id: int # Foreign key to users table + activity_id: int # Foreign key to activities table + activity_type: int # Activity type (1=run, 4=ride, etc.) + pr_date: datetime # When the PR was set + metric: str # PR metric name (e.g., 'fastest_5km') + value: Decimal # PR value (time in seconds, distance in meters, etc.) + unit: str # Unit of measurement ('seconds', 'meters', 'watts', etc.) +``` + +## API Endpoints + +### GET /personal_records/user/{user_id} +Get all personal records for a user. + +**Response:** +```json +[ + { + "id": 1, + "user_id": 123, + "activity_id": 456, + "activity_type": 1, + "pr_date": "2025-10-11T10:30:00", + "metric": "fastest_5km", + "value": "1200.50", + "unit": "seconds" + } +] +``` + +### POST /personal_records/user/{user_id}/recalculate +Recalculate all personal records for a user from scratch. + +**Response:** +```json +{ + "message": "Personal records recalculated successfully" +} +``` + +## Initial Data Migration + +To calculate PRs for existing activities, use the migration script: + +```bash +# Migrate all users +cd backend/app +python -m activities.personal_records.migration + +# Migrate specific user +python -m activities.personal_records.migration 123 +``` + +## Database Migration + +Run Alembic migration to create the personal_records table: + +```bash +cd backend/app +alembic upgrade head +``` + +## Future Enhancements + +1. **Strength Training PRs**: Parse activity_sets to track 1RM for specific exercises +2. **Segment PRs**: Track best times for specific route segments (like Strava segments) +3. **Power-based PRs**: Add 1-min, 5-min, 20-min power PRs for cycling +4. **Notifications**: Alert users when they set a new PR +5. **PR History**: Track historical PRs to see progression over time +6. **Stream-based PRs**: Calculate split times from activity streams for more accurate distance PRs diff --git a/backend/app/activities/personal_records/migration.py b/backend/app/activities/personal_records/migration.py new file mode 100644 index 000000000..185b083a1 --- /dev/null +++ b/backend/app/activities/personal_records/migration.py @@ -0,0 +1,95 @@ +""" +Script to migrate existing activities and calculate personal records. +This can be run as a one-time migration or scheduled as a background task. + +Usage: + python -m activities.personal_records.migration +""" + +import asyncio +from sqlalchemy.orm import Session + +import core.database as core_database +import core.logger as core_logger +import users.user.crud as users_crud +import activities.personal_records.utils as personal_records_utils + + +async def migrate_all_users_prs(): + """ + Migrate all users' activities to calculate personal records. + This is a one-time migration that should be run after the PR feature is deployed. + """ + db = next(core_database.get_db()) + + try: + # Get all users + users = users_crud.get_all_users(db) + + if not users: + core_logger.print_to_log("No users found") + return + + core_logger.print_to_log(f"Starting PR migration for {len(users)} users") + + for user in users: + try: + core_logger.print_to_log(f"Calculating PRs for user {user.id} ({user.username})") + await personal_records_utils.recalculate_all_user_prs(user.id, db) + core_logger.print_to_log(f"Completed PRs for user {user.id}") + except Exception as err: + core_logger.print_to_log( + f"Error calculating PRs for user {user.id}: {err}", + "error", + exc=err + ) + continue + + core_logger.print_to_log("PR migration completed for all users") + + except Exception as err: + core_logger.print_to_log( + f"Error in migrate_all_users_prs: {err}", + "error", + exc=err + ) + raise + finally: + db.close() + + +async def migrate_single_user_prs(user_id: int): + """ + Migrate a single user's activities to calculate personal records. + + Args: + user_id: The ID of the user to migrate + """ + db = next(core_database.get_db()) + + try: + core_logger.print_to_log(f"Calculating PRs for user {user_id}") + await personal_records_utils.recalculate_all_user_prs(user_id, db) + core_logger.print_to_log(f"Completed PRs for user {user_id}") + except Exception as err: + core_logger.print_to_log( + f"Error calculating PRs for user {user_id}: {err}", + "error", + exc=err + ) + raise + finally: + db.close() + + +if __name__ == "__main__": + # Run the migration + import sys + + if len(sys.argv) > 1: + # Migrate specific user + user_id = int(sys.argv[1]) + asyncio.run(migrate_single_user_prs(user_id)) + else: + # Migrate all users + asyncio.run(migrate_all_users_prs())