diff --git a/FitnessSync/.dockerignore b/FitnessSync/.dockerignore new file mode 100644 index 0000000..2b2579d --- /dev/null +++ b/FitnessSync/.dockerignore @@ -0,0 +1,9 @@ +docker/data +.git +.venv +.gemini +__pycache__ +**/*.pyc +.env +.pytest_cache +logs/ diff --git a/FitnessSync/backend/__pycache__/main.cpython-313.pyc b/FitnessSync/backend/__pycache__/main.cpython-313.pyc index 71544e2..3f660c4 100644 Binary files a/FitnessSync/backend/__pycache__/main.cpython-313.pyc and b/FitnessSync/backend/__pycache__/main.cpython-313.pyc differ diff --git a/FitnessSync/backend/alembic/versions/20ccc82af3f2_remove_btree_geometry_indexes.py b/FitnessSync/backend/alembic/versions/20ccc82af3f2_remove_btree_geometry_indexes.py new file mode 100644 index 0000000..77e09af --- /dev/null +++ b/FitnessSync/backend/alembic/versions/20ccc82af3f2_remove_btree_geometry_indexes.py @@ -0,0 +1,26 @@ +"""remove_btree_geometry_indexes + +Revision ID: 20ccc82af3f2 +Revises: 8c5791dd193e +Create Date: 2026-01-13 19:41:50.387988 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '20ccc82af3f2' +down_revision: Union[str, None] = '8c5791dd193e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/FitnessSync/backend/alembic/versions/62a16d820130_add_activity_streams_and_postgis.py b/FitnessSync/backend/alembic/versions/62a16d820130_add_activity_streams_and_postgis.py new file mode 100644 index 0000000..cee6cb6 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/62a16d820130_add_activity_streams_and_postgis.py @@ -0,0 +1,54 @@ +"""add_activity_streams_and_postgis + +Revision ID: 62a16d820130 +Revises: 52a16d820129 +Create Date: 2026-01-13 11:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import geoalchemy2 + +# revision identifiers, used by Alembic. +revision = '62a16d820130' +down_revision = '52a16d820129' +branch_labels = None +depends_on = None + +def upgrade() -> None: + # Enable PostGIS + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + # Create table + op.create_table('activity_streams', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('activity_id', sa.Integer(), nullable=False), + sa.Column('time_offset', postgresql.ARRAY(sa.Integer()), nullable=True), + sa.Column('latitude', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('longitude', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('elevation', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('heart_rate', postgresql.ARRAY(sa.Integer()), nullable=True), + sa.Column('power', postgresql.ARRAY(sa.Integer()), nullable=True), + sa.Column('cadence', postgresql.ARRAY(sa.Integer()), nullable=True), + sa.Column('speed', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('distance', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('temperature', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('moving', postgresql.ARRAY(sa.Boolean()), nullable=True), + sa.Column('grade_smooth', postgresql.ARRAY(sa.Float()), nullable=True), + sa.Column('geom', geoalchemy2.types.Geometry(geometry_type='LINESTRING', srid=4326), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_activity_streams_activity_id'), 'activity_streams', ['activity_id'], unique=False) + + # Check if index exists before creating + conn = op.get_bind() + index_exists = conn.execute(sa.text("SELECT 1 FROM pg_indexes WHERE indexname = 'idx_activity_streams_geom'")).scalar() + if not index_exists: + op.create_index('idx_activity_streams_geom', 'activity_streams', ['geom'], unique=False, postgresql_using='gist') + + op.create_foreign_key(None, 'activity_streams', 'activities', ['activity_id'], ['id']) + + +def downgrade() -> None: + op.drop_table('activity_streams') diff --git a/FitnessSync/backend/alembic/versions/8c5791dd193e_add_missing_activity_columns.py b/FitnessSync/backend/alembic/versions/8c5791dd193e_add_missing_activity_columns.py new file mode 100644 index 0000000..1446cb2 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/8c5791dd193e_add_missing_activity_columns.py @@ -0,0 +1,218 @@ +"""add_missing_activity_columns + +Revision ID: 8c5791dd193e +Revises: 62a16d820130 +Create Date: 2026-01-13 19:30:07.100001 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import geoalchemy2 + +# revision identifiers, used by Alembic. +revision: str = '8c5791dd193e' +down_revision: Union[str, None] = '62a16d820130' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('scheduled_jobs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('job_type', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('interval_minutes', sa.Integer(), nullable=False), + sa.Column('params', sa.Text(), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=True), + sa.Column('last_run', sa.DateTime(timezone=True), nullable=True), + sa.Column('next_run', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_scheduled_jobs_id'), 'scheduled_jobs', ['id'], unique=False) + # op.drop_table('spatial_ref_sys') + op.add_column('activities', sa.Column('garmin_activity_id', sa.String(), nullable=False)) + op.add_column('activities', sa.Column('activity_type', sa.String(), nullable=True)) + op.add_column('activities', sa.Column('start_lat', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('start_lng', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('file_content', sa.LargeBinary(), nullable=True)) + op.add_column('activities', sa.Column('file_type', sa.String(), nullable=True)) + op.add_column('activities', sa.Column('download_status', sa.String(), nullable=True)) + op.add_column('activities', sa.Column('downloaded_at', sa.DateTime(), nullable=True)) + op.add_column('activities', sa.Column('bike_match_confidence', sa.Float(), nullable=True)) + op.alter_column('activities', 'activity_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('activities', 'start_time', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('activities', 'calories', + existing_type=sa.INTEGER(), + type_=sa.Float(), + existing_nullable=True) + op.create_unique_constraint(None, 'activities', ['garmin_activity_id']) + op.drop_column('activities', 'source') + op.drop_column('activities', 'activity_data') + op.drop_column('activities', 'end_time') + op.create_index(op.f('ix_activity_streams_geom'), 'activity_streams', ['geom'], unique=False) + op.create_index(op.f('ix_activity_streams_id'), 'activity_streams', ['id'], unique=False) + op.add_column('auth_status', sa.Column('service_type', sa.String(), nullable=False)) + op.add_column('auth_status', sa.Column('username', sa.String(), nullable=True)) + op.add_column('auth_status', sa.Column('authenticated', sa.Boolean(), nullable=True)) + op.add_column('auth_status', sa.Column('token_expires_at', sa.DateTime(), nullable=True)) + op.add_column('auth_status', sa.Column('last_login', sa.DateTime(), nullable=True)) + op.add_column('auth_status', sa.Column('is_china', sa.Boolean(), nullable=True)) + op.add_column('auth_status', sa.Column('last_check', sa.DateTime(), nullable=True)) + op.drop_constraint('auth_status_service_key', 'auth_status', type_='unique') + op.drop_column('auth_status', 'is_authenticated') + op.drop_column('auth_status', 'last_sync') + op.drop_column('auth_status', 'service') + op.add_column('configurations', sa.Column('fitbit_client_id', sa.String(), nullable=True)) + op.add_column('configurations', sa.Column('fitbit_client_secret', sa.String(), nullable=True)) + op.add_column('configurations', sa.Column('garmin_username', sa.String(), nullable=True)) + op.add_column('configurations', sa.Column('garmin_password', sa.String(), nullable=True)) + op.add_column('configurations', sa.Column('sync_settings', sa.JSON(), nullable=True)) + op.drop_constraint('configurations_key_key', 'configurations', type_='unique') + op.drop_column('configurations', 'key') + op.drop_column('configurations', 'value') + op.add_column('health_metrics', sa.Column('metric_value', sa.Float(), nullable=False)) + op.add_column('health_metrics', sa.Column('timestamp', sa.DateTime(), nullable=False)) + op.add_column('health_metrics', sa.Column('detailed_data', sa.Text(), nullable=True)) + op.alter_column('health_metrics', 'date', + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=False) + op.drop_column('health_metrics', 'value') + op.add_column('segments', sa.Column('geom', geoalchemy2.types.Geometry(geometry_type='LINESTRING', srid=4326, dimension=2, from_text='ST_GeomFromEWKT', name='geometry'), nullable=True)) + + # Check if index exists before creating + conn = op.get_bind() + + idx_exists = conn.execute(sa.text("SELECT 1 FROM pg_indexes WHERE indexname = 'idx_segments_geom'")).scalar() + if not idx_exists: + op.create_index('idx_segments_geom', 'segments', ['geom'], unique=False, postgresql_using='gist') + + ix_exists = conn.execute(sa.text("SELECT 1 FROM pg_indexes WHERE indexname = 'ix_segments_geom'")).scalar() + if not ix_exists: + op.create_index(op.f('ix_segments_geom'), 'segments', ['geom'], unique=False) + op.add_column('sync_logs', sa.Column('operation', sa.String(), nullable=False)) + op.add_column('sync_logs', sa.Column('message', sa.Text(), nullable=True)) + op.add_column('sync_logs', sa.Column('records_processed', sa.Integer(), nullable=True)) + op.add_column('sync_logs', sa.Column('records_failed', sa.Integer(), nullable=True)) + op.add_column('sync_logs', sa.Column('user_id', sa.Integer(), nullable=True)) + op.drop_column('sync_logs', 'source') + op.drop_column('sync_logs', 'records_synced') + op.drop_column('sync_logs', 'error_message') + op.drop_column('sync_logs', 'destination') + op.drop_column('sync_logs', 'sync_type') + op.add_column('weight_records', sa.Column('fitbit_id', sa.String(), nullable=False)) + op.add_column('weight_records', sa.Column('unit', sa.String(), nullable=False)) + op.add_column('weight_records', sa.Column('timestamp', sa.DateTime(), nullable=False)) + op.add_column('weight_records', sa.Column('sync_status', sa.String(), nullable=True)) + op.add_column('weight_records', sa.Column('garmin_id', sa.String(), nullable=True)) + op.alter_column('weight_records', 'date', + existing_type=sa.DATE(), + type_=sa.DateTime(), + existing_nullable=False) + op.create_unique_constraint(None, 'weight_records', ['fitbit_id']) + op.drop_column('weight_records', 'source') + op.drop_column('weight_records', 'body_fat') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('weight_records', sa.Column('body_fat', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.add_column('weight_records', sa.Column('source', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'weight_records', type_='unique') + op.alter_column('weight_records', 'date', + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=False) + op.drop_column('weight_records', 'garmin_id') + op.drop_column('weight_records', 'sync_status') + op.drop_column('weight_records', 'timestamp') + op.drop_column('weight_records', 'unit') + op.drop_column('weight_records', 'fitbit_id') + op.add_column('sync_logs', sa.Column('sync_type', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('sync_logs', sa.Column('destination', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('sync_logs', sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True)) + op.add_column('sync_logs', sa.Column('records_synced', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('sync_logs', sa.Column('source', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_column('sync_logs', 'user_id') + op.drop_column('sync_logs', 'records_failed') + op.drop_column('sync_logs', 'records_processed') + op.drop_column('sync_logs', 'message') + op.drop_column('sync_logs', 'operation') + op.drop_index(op.f('ix_segments_geom'), table_name='segments') + op.drop_index('idx_segments_geom', table_name='segments', postgresql_using='gist') + op.drop_column('segments', 'geom') + op.add_column('health_metrics', sa.Column('value', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False)) + op.alter_column('health_metrics', 'date', + existing_type=sa.DateTime(), + type_=sa.DATE(), + existing_nullable=False) + op.drop_column('health_metrics', 'detailed_data') + op.drop_column('health_metrics', 'timestamp') + op.drop_column('health_metrics', 'metric_value') + op.add_column('configurations', sa.Column('value', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('configurations', sa.Column('key', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.create_unique_constraint('configurations_key_key', 'configurations', ['key']) + op.drop_column('configurations', 'sync_settings') + op.drop_column('configurations', 'garmin_password') + op.drop_column('configurations', 'garmin_username') + op.drop_column('configurations', 'fitbit_client_secret') + op.drop_column('configurations', 'fitbit_client_id') + op.add_column('auth_status', sa.Column('service', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('auth_status', sa.Column('last_sync', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.add_column('auth_status', sa.Column('is_authenticated', sa.BOOLEAN(), autoincrement=False, nullable=False)) + op.create_unique_constraint('auth_status_service_key', 'auth_status', ['service']) + op.drop_column('auth_status', 'last_check') + op.drop_column('auth_status', 'is_china') + op.drop_column('auth_status', 'last_login') + op.drop_column('auth_status', 'token_expires_at') + op.drop_column('auth_status', 'authenticated') + op.drop_column('auth_status', 'username') + op.drop_column('auth_status', 'service_type') + op.drop_index(op.f('ix_activity_streams_id'), table_name='activity_streams') + op.drop_index(op.f('ix_activity_streams_geom'), table_name='activity_streams') + op.add_column('activities', sa.Column('end_time', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.add_column('activities', sa.Column('activity_data', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.add_column('activities', sa.Column('source', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'activities', type_='unique') + op.alter_column('activities', 'calories', + existing_type=sa.Float(), + type_=sa.INTEGER(), + existing_nullable=True) + op.alter_column('activities', 'start_time', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.alter_column('activities', 'activity_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.drop_column('activities', 'bike_match_confidence') + op.drop_column('activities', 'downloaded_at') + op.drop_column('activities', 'download_status') + op.drop_column('activities', 'file_type') + op.drop_column('activities', 'file_content') + op.drop_column('activities', 'start_lng') + op.drop_column('activities', 'start_lat') + op.drop_column('activities', 'activity_type') + op.drop_column('activities', 'garmin_activity_id') + # op.create_table('spatial_ref_sys', + # sa.Column('srid', sa.INTEGER(), autoincrement=False, nullable=False), + # sa.Column('auth_name', sa.VARCHAR(length=256), autoincrement=False, nullable=True), + # sa.Column('auth_srid', sa.INTEGER(), autoincrement=False, nullable=True), + # sa.Column('srtext', sa.VARCHAR(length=2048), autoincrement=False, nullable=True), + # sa.Column('proj4text', sa.VARCHAR(length=2048), autoincrement=False, nullable=True), + # sa.CheckConstraint('srid > 0 AND srid <= 998999', name='spatial_ref_sys_srid_check'), + # sa.PrimaryKeyConstraint('srid', name='spatial_ref_sys_pkey') + # ) + op.drop_index(op.f('ix_scheduled_jobs_id'), table_name='scheduled_jobs') + op.drop_table('scheduled_jobs') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py b/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py index 267209d..9131dbe 100644 --- a/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py +++ b/FitnessSync/backend/alembic/versions/95af0e911216_add_bike_setups_table.py @@ -25,6 +25,9 @@ def upgrade() -> None: sa.Column('frame', sa.String(), nullable=False), sa.Column('chainring', sa.Integer(), nullable=False), sa.Column('rear_cog', sa.Integer(), nullable=False), + sa.Column('purchase_date', sa.DateTime(), nullable=True), + sa.Column('weight_kg', sa.Float(), nullable=True), + sa.Column('retirement_date', sa.DateTime(), nullable=True), sa.Column('name', sa.String(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), diff --git a/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc deleted file mode 100644 index 0091640..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc deleted file mode 100644 index afbe5cf..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/1e157f880117_create_jobs_table.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/20ccc82af3f2_remove_btree_geometry_indexes.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/20ccc82af3f2_remove_btree_geometry_indexes.cpython-311.pyc new file mode 100644 index 0000000..2019efd Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/20ccc82af3f2_remove_btree_geometry_indexes.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/24df1381ac00_initial_migration.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/24df1381ac00_initial_migration.cpython-313.pyc deleted file mode 100644 index 6661b6e..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/24df1381ac00_initial_migration.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc deleted file mode 100644 index b95473f..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/299d39b0f13d_add_mfa_state_to_api_tokens.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/62a16d820130_add_activity_streams_and_postgis.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/62a16d820130_add_activity_streams_and_postgis.cpython-311.pyc new file mode 100644 index 0000000..1535a7d Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/62a16d820130_add_activity_streams_and_postgis.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/73e349ef1d88_add_bike_setup_to_activity.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/73e349ef1d88_add_bike_setup_to_activity.cpython-313.pyc deleted file mode 100644 index dfe9e77..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/73e349ef1d88_add_bike_setup_to_activity.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/792840bbb2e0_allow_null_tokens_and_expiry_during_mfa.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/792840bbb2e0_allow_null_tokens_and_expiry_during_mfa.cpython-313.pyc deleted file mode 100644 index 59375aa..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/792840bbb2e0_allow_null_tokens_and_expiry_during_mfa.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-313.pyc deleted file mode 100644 index 52520cc..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/85c60ed462bf_add_state_tables.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/8c5791dd193e_add_missing_activity_columns.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/8c5791dd193e_add_missing_activity_columns.cpython-311.pyc new file mode 100644 index 0000000..41e919c Binary files /dev/null and b/FitnessSync/backend/alembic/versions/__pycache__/8c5791dd193e_add_missing_activity_columns.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-311.pyc index f74392b..5468518 100644 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-311.pyc and b/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-313.pyc deleted file mode 100644 index 2c0efa5..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/95af0e911216_add_bike_setups_table.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc deleted file mode 100644 index 9eda1ea..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc deleted file mode 100644 index 1a7ee39..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc deleted file mode 100644 index e83e7c2..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/b5a6d7ef97a5_add_fitbit_redirect_uri.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc index 3af1388..60aa816 100644 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc and b/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-311.pyc differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc deleted file mode 100644 index 93bf42e..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/bd21a0528865_expand_activity_schema_metrics.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/__pycache__/ce0f0282a142_add_mfa_session_fields_to_api_tokens.cpython-313.pyc b/FitnessSync/backend/alembic/versions/__pycache__/ce0f0282a142_add_mfa_session_fields_to_api_tokens.cpython-313.pyc deleted file mode 100644 index 9039bf8..0000000 Binary files a/FitnessSync/backend/alembic/versions/__pycache__/ce0f0282a142_add_mfa_session_fields_to_api_tokens.cpython-313.pyc and /dev/null differ diff --git a/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py b/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py index 6404a12..e63a5b7 100644 --- a/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py +++ b/FitnessSync/backend/alembic/versions/bd21a0528865_expand_activity_schema_metrics.py @@ -20,8 +20,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column('activities', sa.Column('distance', sa.Float(), nullable=True)) - op.add_column('activities', sa.Column('calories', sa.Float(), nullable=True)) + # distance and calories already exist in initial_migration + # op.add_column('activities', sa.Column('distance', sa.Float(), nullable=True)) + # op.add_column('activities', sa.Column('calories', sa.Float(), nullable=True)) op.add_column('activities', sa.Column('avg_hr', sa.Integer(), nullable=True)) op.add_column('activities', sa.Column('max_hr', sa.Integer(), nullable=True)) op.add_column('activities', sa.Column('avg_speed', sa.Float(), nullable=True)) @@ -59,6 +60,6 @@ def downgrade() -> None: op.drop_column('activities', 'avg_speed') op.drop_column('activities', 'max_hr') op.drop_column('activities', 'avg_hr') - op.drop_column('activities', 'calories') - op.drop_column('activities', 'distance') + # op.drop_column('activities', 'calories') + # op.drop_column('activities', 'distance') # ### end Alembic commands ### diff --git a/FitnessSync/backend/backfill_segment_geom.py b/FitnessSync/backend/backfill_segment_geom.py new file mode 100644 index 0000000..8526f71 --- /dev/null +++ b/FitnessSync/backend/backfill_segment_geom.py @@ -0,0 +1,52 @@ +import sys +import os +import json +import logging +from sqlalchemy import text # Import text for raw SQL + +# Add backend to path +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from src.services.postgresql_manager import PostgreSQLManager +from src.models.segment import Segment +from src.utils.config import config + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def main(): + logger.info("Starting Segment geometry backfill...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + + with db_manager.get_db_session() as session: + segments = session.query(Segment).filter(Segment.geom == None).all() + logger.info(f"Found {len(segments)} segments needing geometry backfill.") + + count = 0 + for seg in segments: + try: + points_data = json.loads(seg.points) if isinstance(seg.points, str) else seg.points + if not points_data or len(points_data) < 2: + logger.warning(f"Segment {seg.id} has insufficient points.") + continue + + # Points format: [[lon, lat], ...] or [[lon, lat, ele], ...] + # WKT: LINESTRING(lon lat, lon lat, ...) + coords = [] + for p in points_data: + if len(p) >= 2: + coords.append(f"{p[0]} {p[1]}") + + if coords: + wkt = f"SRID=4326;LINESTRING({', '.join(coords)})" + # We can set string to geom column if using geoalchemy2, it handles WKT + seg.geom = wkt + count += 1 + except Exception as e: + logger.error(f"Error processing segment {seg.id}: {e}") + + session.commit() + logger.info(f"Backfilled {count} segments.") + +if __name__ == "__main__": + main() diff --git a/FitnessSync/backend/backfill_streams.py b/FitnessSync/backend/backfill_streams.py new file mode 100644 index 0000000..845717c --- /dev/null +++ b/FitnessSync/backend/backfill_streams.py @@ -0,0 +1,29 @@ +import sys +import os +import logging + +# Add backend to path +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from src.services.postgresql_manager import PostgreSQLManager +from src.services.sync_app import SyncApp +from src.services.garmin.client import GarminClient +from src.utils.config import config + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def main(): + logger.info("Starting stream backfill...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + + with db_manager.get_db_session() as session: + # Client not needed for local reparse but SyncApp requires it + client = GarminClient() + app = SyncApp(session, client) + + result = app.activity_sync.reparse_local_files() + logger.info(f"Backfill complete: {result}") + +if __name__ == "__main__": + main() diff --git a/FitnessSync/backend/requirements.txt b/FitnessSync/backend/requirements.txt index e4d45b5..fa119d8 100644 --- a/FitnessSync/backend/requirements.txt +++ b/FitnessSync/backend/requirements.txt @@ -16,3 +16,4 @@ pytest==7.4.3 pytest-asyncio==0.21.1 alembic==1.13.1 fitdecode>=0.10.0 +geoalchemy2>=0.14.0 diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc index ca80274..4f41bca 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc index 157060e..6b2ae3f 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/activities.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc index 4b6a557..4af2e1d 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/analysis.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-313.pyc new file mode 100644 index 0000000..c8836a0 Binary files /dev/null and b/FitnessSync/backend/src/api/__pycache__/analysis.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-313.pyc index 50d2df2..8b78587 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/bike_setups.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc index 36547b6..acdcb6a 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/discovery.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc index 5cc30c9..9cee51b 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/metrics.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc index e481cb3..036b8d4 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc index 3a9470b..1af445e 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/scheduling.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc index f5a0c24..e6a7cd6 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/segments.cpython-313.pyc b/FitnessSync/backend/src/api/__pycache__/segments.cpython-313.pyc index 3fb2d31..32cdd79 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/segments.cpython-313.pyc and b/FitnessSync/backend/src/api/__pycache__/segments.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/api/__pycache__/sync.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/sync.cpython-311.pyc index e6f5c06..220b6cf 100644 Binary files a/FitnessSync/backend/src/api/__pycache__/sync.cpython-311.pyc and b/FitnessSync/backend/src/api/__pycache__/sync.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/api/activities.py b/FitnessSync/backend/src/api/activities.py index e83c4f7..691ff52 100644 --- a/FitnessSync/backend/src/api/activities.py +++ b/FitnessSync/backend/src/api/activities.py @@ -855,71 +855,74 @@ async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)): """ try: activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first() - if not activity or not activity.file_content: - raise HTTPException(status_code=404, detail="Activity or file content not found") + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + # 1. Try fetching high-res streams from ActivityStream table + from ..models.stream import ActivityStream + stream_record = db.query(ActivityStream).filter_by(activity_id=activity.id).first() - # 1. Check DB Cache - if activity.streams_json: - return activity.streams_json - - if not activity.file_content: - raise HTTPException(status_code=404, detail="Activity file content not found") - - # 2. Extract streams - try: - streams = {} - if activity.file_type == 'fit': - streams = _extract_streams_from_fit(activity.file_content) - elif activity.file_type == 'tcx': - # Need to define or import this. Let's use parsers definition if possible, - # but _extract_streams_from_fit is local. - # Let's inspect parsers.py again. It returns data dict which is similar to streams. - # We can map it. - from ..services.parsers import extract_activity_data - data = extract_activity_data(activity.file_content, 'tcx') - # Mapping parsers format to streams format - streams = { - "time": [(t - data['timestamps'][0]).total_seconds() for t in data['timestamps']] if data['timestamps'] else [], - "heart_rate": data['heart_rate'], - "power": data['power'], - "altitude": [p[2] if len(p) > 2 else None for p in data['points']], - "speed": data.get('speed', []), - "cadence": data.get('cadence', []), - "respiration_rate": data.get('respiration_rate', []) + if stream_record: + # Map DB columns to API response format + return { + "time": stream_record.time_offset or [], + "heart_rate": stream_record.heart_rate or [], + "power": stream_record.power or [], + "altitude": stream_record.elevation or [], + "speed": stream_record.speed or [], + "cadence": stream_record.cadence or [], + "respiration_rate": [], # Add if needed, not in ActivityStream model currently? Oh, I didn't add it to model? + # Actually I should check if I added respiration_rate to ActivityStream model in step 61/105 migration. + # In migration: I did NOT adding respiration_rate. + # In parsers.py: I only capture 'time_offset', 'latitude', 'longitude', 'elevation', 'heart_rate', 'power', 'cadence', 'speed', 'distance', 'temperature', 'moving', 'grade_smooth'. + # The old endpoint had respiration_rate. + # If it's missing, I'll return empty or I should have added it. + # For now return empty list to avoid breaking frontend. + "distance": stream_record.distance or [], + "temperature": stream_record.temperature or [] } - else: - # Try FIT as fallback or empty - logger.warning(f"Unsupported file type for streams: {activity.file_type}") - streams = {} - # 3. Cache the result (downsampling happened in _extract_streams_from_fit, need it for TCX too?) - # _extract_streams_from_fit now has LTTB at the end. - # We should move LTTB to a common helper "process_streams" to apply to both. - - # Apply Downsampling if not already done (for TCX) - if activity.file_type == 'tcx' and streams.get("time"): - from ..utils.algorithms import lttb - target_points = 1500 - count = len(streams["time"]) - if count > target_points: - step = count / target_points - indices = [int(i * step) for i in range(target_points)] - if indices[-1] != count - 1: indices[-1] = count - 1 - sampled_streams = {k: [] for k in streams} - for idx in indices: - for k in streams: - if streams.get(k) and idx < len(streams[k]): - sampled_streams[k].append(streams[k][idx]) - else: - sampled_streams[k].append(None) - streams = sampled_streams - - # Save to DB - if streams: - activity.streams_json = streams - db.commit() - - return streams + # 2. Check DB Cache (Legacy) + if activity.streams_json: + return activity.streams_json + + if not activity.file_content: + # Just return empty if no file and no streams + return {} + + # 3. Fallback: Parse on the fly AND save to DB + # This mirrors the behavior of lazy loading but using the new robust table + try: + from ..services.sync.activity import GarminActivitySync # avoid circular imports if possible, or use parser directly + # Actually better to just use parser and save manually here or import the function. + # But the logic is already in GarminActivitySync._save_activity_streams. + # However, GarminActivitySync needs GarminClient init. + # Let's just use the parser directly and insert like in _save_activity_streams + + from ..services.parsers import parse_fit_to_streams + data = parse_fit_to_streams(activity.file_content) + if data: + # Save to DB + new_stream = ActivityStream(activity_id=activity.id, **data) + db.add(new_stream) + db.commit() + + return { + "time": data['time_offset'], + "heart_rate": data['heart_rate'], + "power": data['power'], + "altitude": data['elevation'], + "speed": data['speed'], + "cadence": data['cadence'], + "distance": data['distance'], + "temperature": data['temperature'], + "respiration_rate": [] + } + except Exception as e: + logger.error(f"Error lazy parsing streams: {e}") + + return {} # Return empty if all fails + except Exception as e: logger.error(f"Error processing streams: {e}") raise HTTPException(status_code=500, detail="Error processing activity streams") diff --git a/FitnessSync/backend/src/api/analysis.py b/FitnessSync/backend/src/api/analysis.py index 45cdc24..04b1d34 100644 --- a/FitnessSync/backend/src/api/analysis.py +++ b/FitnessSync/backend/src/api/analysis.py @@ -46,7 +46,7 @@ class EffortAnalysisData(BaseModel): class ComparisonResponse(BaseModel): efforts: List[EffortAnalysisData] - winners: Dict[str, int] # metric_key -> effort_id of winner + winners: Dict[str, Optional[int]] # metric_key -> effort_id of winner @router.post("/segments/efforts/compare", response_model=ComparisonResponse) def compare_efforts(effort_ids: List[int] = Body(...), db: Session = Depends(get_db)): @@ -162,58 +162,94 @@ def export_analysis(effort_ids: List[int] = Body(...), db: Session = Depends(get # Or simplistic: Fetch inside loop (N+1 query, but export is rare/manual action). effort = db.query(SegmentEffort).get(e_dict['effort_id']) - if effort and effort.activity and effort.activity.file_content: + if effort and effort.activity: try: act = effort.activity - raw_data = extract_activity_data(act.file_content, act.file_type or 'fit') + streams = {} - # Slice by time - # Timestamps in raw_data['timestamps'] - timestamps = raw_data.get('timestamps', []) - - start_time = effort.start_time - end_time = effort.end_time - - # Normalize start/end to match stream timestamps timezone - if timestamps and timestamps[0]: - stream_tz = timestamps[0].tzinfo + # 1. Use ActivityStream table (Preferred) + if act.streams and act.streams.time_offset: + ast = act.streams + base_time = act.start_time - # Helper to align + # Reconstruct absolute timestamps + full_timestamps = [base_time + __import__('datetime').timedelta(seconds=t) for t in ast.time_offset] + + # Alignment helper def align_tz(dt, target_tz): - if dt.tzinfo == target_tz: - return dt - if dt.tzinfo is None and target_tz is not None: - return dt.replace(tzinfo=target_tz) # Assume same ref - if dt.tzinfo is not None and target_tz is None: - return dt.replace(tzinfo=None) # Strip + if not target_tz: return dt.replace(tzinfo=None) # naive + if dt.tzinfo == target_tz: return dt + if dt.tzinfo is None: return dt.replace(tzinfo=target_tz) return dt.astimezone(target_tz) - start_time = align_tz(start_time, stream_tz) - end_time = align_tz(end_time, stream_tz) - - # Simple list comprehension to find indices - indices = [i for i, t in enumerate(timestamps) - if t and start_time <= t <= end_time] - - streams = {} - if indices: - first = indices[0] - last = indices[-1] + 1 + start_time = align_tz(effort.start_time, full_timestamps[0].tzinfo if full_timestamps else None) + end_time = align_tz(effort.end_time, full_timestamps[0].tzinfo if full_timestamps else None) + + # Find slice indices + # Since sorted, can optimize, but simple loop fine for export + indices = [i for i, t in enumerate(full_timestamps) if start_time <= t <= end_time] - # Keys to extract - keys = ['heart_rate', 'power', 'speed', 'cadence', 'temperature'] - for k in keys: - if k in raw_data: - streams[k] = raw_data[k][first:last] - - # Points/Elevation - if 'points' in raw_data: - sliced_points = raw_data['points'][first:last] - streams['latlng'] = [[p[1], p[0]] for p in sliced_points] # lat, lon - streams['elevation'] = [p[2] if len(p) > 2 else None for p in sliced_points] + if indices: + first = indices[0] + last = indices[-1] + 1 - streams['timestamps'] = [t.isoformat() if t else None for t in raw_data['timestamps'][first:last]] + # Extract slices + streams['timestamps'] = [t.isoformat() for t in full_timestamps[first:last]] + + if ast.heart_rate: streams['heart_rate'] = ast.heart_rate[first:last] + if ast.power: streams['power'] = ast.power[first:last] + if ast.speed: streams['speed'] = ast.speed[first:last] + if ast.cadence: streams['cadence'] = ast.cadence[first:last] + if ast.temperature: streams['temperature'] = ast.temperature[first:last] + if ast.elevation: streams['elevation'] = ast.elevation[first:last] + + # LatLng - reconstruct from parallel arrays + if ast.latitude and ast.longitude: + lats = ast.latitude[first:last] + lngs = ast.longitude[first:last] + # Zip only up to shortest length to avoid errors + min_len = min(len(lats), len(lngs)) + streams['latlng'] = [[lats[i], lngs[i]] for i in range(min_len)] + + # 2. Fallback to File Parsing + elif act.file_content: + raw_data = extract_activity_data(act.file_content, act.file_type or 'fit') + # Slice by time + timestamps = raw_data.get('timestamps', []) + start_time = effort.start_time + end_time = effort.end_time + + if timestamps and timestamps[0]: + stream_tz = timestamps[0].tzinfo + + def align_tz_fallback(dt, target_tz): + if dt.tzinfo == target_tz: return dt + if dt.tzinfo is None and target_tz is not None: return dt.replace(tzinfo=target_tz) + if dt.tzinfo is not None and target_tz is None: return dt.replace(tzinfo=None) + return dt.astimezone(target_tz) + + start_time = align_tz_fallback(start_time, stream_tz) + end_time = align_tz_fallback(end_time, stream_tz) + + indices = [i for i, t in enumerate(timestamps) if t and start_time <= t <= end_time] + + if indices: + first = indices[0] + last = indices[-1] + 1 + + keys = ['heart_rate', 'power', 'speed', 'cadence', 'temperature'] + for k in keys: + if k in raw_data: + streams[k] = raw_data[k][first:last] + + if 'points' in raw_data: + sliced_points = raw_data['points'][first:last] + streams['latlng'] = [[p[1], p[0]] for p in sliced_points] # lat, lon + streams['elevation'] = [p[2] if len(p) > 2 else None for p in sliced_points] + + streams['timestamps'] = [t.isoformat() if t else None for t in raw_data['timestamps'][first:last]] + e_dict['streams'] = streams except Exception as e: print(f"Error extracting streams for effort {effort.id}: {e}") diff --git a/FitnessSync/backend/src/api/discovery.py b/FitnessSync/backend/src/api/discovery.py index 7eba267..6b70c1d 100644 --- a/FitnessSync/backend/src/api/discovery.py +++ b/FitnessSync/backend/src/api/discovery.py @@ -61,7 +61,13 @@ def discover_single_activity( ): service = SegmentDiscoveryService(db) - candidates = service.analyze_single_activity(request.activity_id) + candidates = service.analyze_single_activity( + activity_id=request.activity_id, + pause_threshold=request.pause_threshold, + rdp_epsilon=request.rdp_epsilon, + turn_threshold=request.turn_threshold, + min_length=request.min_length + ) # Convert to schema results = [] diff --git a/FitnessSync/backend/src/api/metrics.py b/FitnessSync/backend/src/api/metrics.py index 80ad212..31cd8ca 100644 --- a/FitnessSync/backend/src/api/metrics.py +++ b/FitnessSync/backend/src/api/metrics.py @@ -296,34 +296,20 @@ from ..services.job_manager import job_manager from ..models.health_state import HealthSyncState from ..utils.config import config from ..services.postgresql_manager import PostgreSQLManager -from ..tasks.definitions import run_health_scan_job, run_health_sync_job, run_fitbit_sync_job +# Sync triggers are handled in sync.py to avoid route duplication and circular imports. +# Kept run_fitbit_sync_job for sync_fitbit_trigger above if needed, +# although sync.py likely handles that too. +# sync.py has /sync/fitbit/weight. metrics.py has /metrics/sync/fitbit. +# They might be distinct (metrics vs weight). +# run_fitbit_sync_job in definitions.py calls sync_fitbit_weight? +# "run_fitbit_sync_job: Background task wrapper for fitbit sync ... sync_fitbit_weight" +# So they are duplicate too. I should probably suppress this one too. +# But users might rely on it. +# I will fix the import for now. -# Removed inline run_health_scan_job and run_health_sync_job +from ..tasks.definitions import run_fitbit_sync_job - -# Definitions moved to tasks/definitions.py - - -@router.post("/metrics/sync/scan") -async def scan_health_trigger(background_tasks: BackgroundTasks): - """Trigger background scan of health gaps""" - job_id = job_manager.create_job("scan_health_metrics") - - db_manager = PostgreSQLManager(config.DATABASE_URL) - background_tasks.add_task(run_health_scan_job, job_id, db_manager.get_db_session) - return {"job_id": job_id, "status": "started"} - -@router.post("/metrics/sync/pending") -async def sync_pending_health_trigger( - background_tasks: BackgroundTasks, - limit: Optional[int] = Query(None, description="Limit number of days/metrics to sync") -): - """Trigger background sync of pending health metrics""" - job_id = job_manager.create_job("sync_pending_health_metrics") - - db_manager = PostgreSQLManager(config.DATABASE_URL) - background_tasks.add_task(run_health_sync_job, job_id, limit, db_manager.get_db_session) - return {"job_id": job_id, "status": "started"} +# Deleted duplicate scan and pending triggers. @router.get("/metrics/sync/status") async def get_health_sync_status_summary(db: Session = Depends(get_db)): diff --git a/FitnessSync/backend/src/api/scheduling.py b/FitnessSync/backend/src/api/scheduling.py index 5096421..c58b182 100644 --- a/FitnessSync/backend/src/api/scheduling.py +++ b/FitnessSync/backend/src/api/scheduling.py @@ -144,3 +144,14 @@ def run_scheduled_job(job_id: int): if scheduler.trigger_job(job_id): return {"status": "triggered", "message": f"Job {job_id} triggered successfully"} raise HTTPException(status_code=404, detail="Job not found") + +@router.post("/scheduling/jobs/reset-defaults", status_code=200) +def reset_scheduled_jobs_defaults(): + """Reset all scheduled jobs to system defaults.""" + from ..services.scheduler import scheduler + try: + scheduler.reset_defaults() + return {"status": "success", "message": "Scheduled jobs reset to defaults."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/FitnessSync/backend/src/api/segments.py b/FitnessSync/backend/src/api/segments.py index 0d93312..a35c244 100644 --- a/FitnessSync/backend/src/api/segments.py +++ b/FitnessSync/backend/src/api/segments.py @@ -105,13 +105,25 @@ def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): if raw_type: rt = raw_type.lower() - if rt in ['running', 'trail_running', 'treadmill_running', 'hiking', 'walking', 'casual_walking']: + print(f"DEBUG SEGMENT TYPE: Raw='{raw_type}' Lower='{rt}'") + if rt in ['running', 'trail_running', 'treadmill_running', 'hiking', 'walking', 'casual_walking', 'run', 'track_running', 'street_running']: final_type = 'running' - elif rt in ['cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling']: + elif rt in ['cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling', 'cycle', 'bike', 'biking']: final_type = 'cycling' else: final_type = rt + print(f"DEBUG SEGMENT TYPE: Fallback to '{final_type}'") + # Create WKT for Geometry + wkt_coords = [] + for p in simplified_points: + if len(p) >= 2: + wkt_coords.append(f"{p[0]} {p[1]}") + + geom_wkt = None + if wkt_coords: + geom_wkt = f"SRID=4326;LINESTRING({', '.join(wkt_coords)})" + # Create Segment segment = Segment( name=payload.name, @@ -120,7 +132,8 @@ def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): elevation_gain=elev_gain, activity_type=final_type, points=json.dumps(simplified_points), - bounds=json.dumps(bounds) + bounds=json.dumps(bounds), + geom=geom_wkt ) db.add(segment) db.commit() @@ -275,6 +288,26 @@ def scan_segments(background_tasks: BackgroundTasks, force: bool = Query(True, d thread.start() return {"message": "Segment scan started", "job_id": job_id} +@router.post("/segments/{segment_id}/scan") +def scan_segment_matches(segment_id: int, db: Session = Depends(get_db)): + """ + Inverted Scan: Find all activities that match this specific segment. + Much faster than scanning all activities against all segments. + """ + from ..services.segment_matcher import SegmentMatcher + + segment = db.query(Segment).filter(Segment.id == segment_id).first() + if not segment: + raise HTTPException(status_code=404, detail="Segment not found") + + try: + matcher = SegmentMatcher(db) + matches = matcher.scan_segment(segment) + return {"message": "Scan complete", "matches_found": matches} + except Exception as e: + print(f"Error scanning segment {segment_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + @router.post("/segments/scan/{activity_id}") def scan_activity_segments(activity_id: int, db: Session = Depends(get_db)): """Scan a specific activity for segment matches.""" diff --git a/FitnessSync/backend/src/api/sync.py b/FitnessSync/backend/src/api/sync.py index 448364b..80565ab 100644 --- a/FitnessSync/backend/src/api/sync.py +++ b/FitnessSync/backend/src/api/sync.py @@ -67,8 +67,6 @@ def get_db(): from ..services.garth_helper import load_and_verify_garth_session from ..tasks.definitions import ( run_activity_sync_task, - run_metrics_sync_task, - run_health_scan_job, run_fitbit_sync_job, run_garmin_upload_job, run_health_sync_job @@ -101,11 +99,11 @@ def sync_metrics(request: SyncMetricsRequest, background_tasks: BackgroundTasks, job_id = job_manager.create_job("Health Metrics Sync") db_manager = PostgreSQLManager(config.DATABASE_URL) - background_tasks.add_task(run_metrics_sync_task, job_id, request.days_back, db_manager.get_db_session) + background_tasks.add_task(run_health_sync_job, job_id, request.days_back, db_manager.get_db_session) return SyncResponse( status="started", - message="Health metrics sync started in background", + message="Health metrics sync (Scan+Sync) started in background", job_id=job_id ) @@ -114,14 +112,14 @@ async def scan_health_trigger( background_tasks: BackgroundTasks, days_back: int = Query(30, description="Number of days to scan back") ): - """Trigger background scan of health gaps""" - job_id = job_manager.create_job("scan_health_metrics") + """Trigger background health sync (Scan + Sync)""" + job_id = job_manager.create_job("Health Sync (Manual)") db_manager = PostgreSQLManager(config.DATABASE_URL) - background_tasks.add_task(run_health_scan_job, job_id, days_back, db_manager.get_db_session) + background_tasks.add_task(run_health_sync_job, job_id, days_back, db_manager.get_db_session) return SyncResponse( status="started", - message="Health metrics scan started in background", + message="Health metrics sync started in background", job_id=job_id ) diff --git a/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc b/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc index 625e5bd..fe1541a 100644 Binary files a/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc and b/FitnessSync/backend/src/jobs/__pycache__/segment_matching_job.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/jobs/segment_matching_job.py b/FitnessSync/backend/src/jobs/segment_matching_job.py index 383a810..519ba0b 100644 --- a/FitnessSync/backend/src/jobs/segment_matching_job.py +++ b/FitnessSync/backend/src/jobs/segment_matching_job.py @@ -28,134 +28,40 @@ def run_segment_matching_job(job_id: str, db_session_factory=None, force: bool = with get_session() as db: try: - # 2. Get all activities and segments - activities = db.query(Activity).all() - total_activities = len(activities) + # 2. Get all segments + segments = db.query(Segment).all() + total_segments = len(segments) - # Optimization: Pre-fetch segment locations for coarse filtering - # Also fetch max created_at for segments - from sqlalchemy.sql import func - max_seg_date = db.query(func.max(Segment.created_at)).scalar() - - segments_list = db.query(Segment).all() - segment_locations = [] - - # Parse segment bounds once - import json - for s in segments_list: - try: - if s.bounds: - # bounds: [min_lat, min_lon, max_lat, max_lon] - # We just need a center point or use bounds directly - b = json.loads(s.bounds) if isinstance(s.bounds, str) else s.bounds - if b and len(b) == 4: - segment_locations.append(b) - except: pass - - has_segments = len(segment_locations) > 0 - - job_manager.update_job(job_id, progress=0, message=f"Starting scan of {total_activities} activities...") + job_manager.update_job(job_id, progress=0, message=f"Starting scan of {total_segments} segments...") matcher = SegmentMatcher(db) total_matches = 0 - skipped_far = 0 - skipped_up_to_date = 0 - - # APPROX 1000 miles in degrees - # 1 deg lat ~ 69 miles. 1000 miles ~ 14.5 degrees. - # Longitude varies but 14.5 is a safe upper bound (it's less distance at poles). - # Let's use 15 degrees buffer. - BUFFER_DEG = 15.0 - - for i, activity in enumerate(activities): + for i, segment in enumerate(segments): if job_manager.should_cancel(job_id): logger.info(f"Job {job_id} cancelled.") return # Calculate progress - prog = int((i / total_activities) * 100) - job_manager.update_job(job_id, progress=prog, message=f"Scanning {i+1}/{total_activities} (Matches: {total_matches}, Skipped Dist: {skipped_far}, Up-to-date: {skipped_up_to_date})") + prog = int((i / total_segments) * 100) + matches = 0 - # OPTIMIZATION: Check if already scanned - last_scan = activity.last_segment_scan_timestamp - scan_date_cutoff = None - - if not force and last_scan and max_seg_date: - # Fix potential TZ mismatch - # Ensure both are timezone-aware (UTC) - from datetime import timezone - - ls_aware = last_scan - if ls_aware.tzinfo is None: - ls_aware = ls_aware.replace(tzinfo=timezone.utc) - - msd_aware = max_seg_date - if msd_aware.tzinfo is None: - msd_aware = msd_aware.replace(tzinfo=timezone.utc) - - if ls_aware >= msd_aware: - # Activity is up to date with all segments - skipped_up_to_date += 1 - continue - else: - # Only scan against NEW segments - scan_date_cutoff = last_scan - - # Check for content first - if not activity.file_content: - continue - - # OPTIMIZATION: Check Coarse Distance - # If activity has start location, check if it's "close" to ANY segment - if has_segments and activity.start_lat is not None and activity.start_lng is not None: - is_near_any = False - a_lat = activity.start_lat - a_lng = activity.start_lng - - for b in segment_locations: - # b: [min_lat, min_lon, max_lat, max_lon] - # Expand bounds by buffer - # Check if point is inside expanded bounds - if (b[0] - BUFFER_DEG <= a_lat <= b[2] + BUFFER_DEG) and \ - (b[1] - BUFFER_DEG <= a_lng <= b[3] + BUFFER_DEG): - is_near_any = True - break - - if not is_near_any: - # Skip parsing! - skipped_far += 1 - continue - - # Extract points - cache this? - # For now, re-extract. It's CPU intensive but safe. try: - points = extract_points_from_file(activity.file_content, activity.file_type) - if points: - # Clear existing efforts ONLY if doing a full re-scan - if not scan_date_cutoff: - db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() - - efforts = matcher.match_activity(activity, points, min_created_at=scan_date_cutoff) - total_matches += len(efforts) - - # Update scan timestamp - activity.last_segment_scan_timestamp = func.now() - - if efforts: - logger.info(f"Activity {activity.id}: {len(efforts)} matches") - + # Use scan_segment with overwrite=False unless forced + # This leverages PostGIS inverted index AND skips already matched activities + matches = matcher.scan_segment(segment, overwrite=force) + total_matches += matches + except Exception as e: - logger.error(f"Error processing activity {activity.id}: {e}") - # Continue to next + logger.error(f"Error scanning segment {segment.id} ({segment.name}): {e}") + + job_manager.update_job(job_id, progress=prog, message=f"Scanning Segment {i+1}/{total_segments}: {segment.name} ({matches} hits)") db.commit() # Final commit job_manager.complete_job(job_id, result={ "total_matches": total_matches, - "activities_scanned": total_activities, - "skipped_due_to_distance": skipped_far + "segments_scanned": total_segments }) - except Exception as e: logger.error(f"Job {job_id} failed: {e}") diff --git a/FitnessSync/backend/src/models/__init__.py b/FitnessSync/backend/src/models/__init__.py index 3176d28..c73649b 100644 --- a/FitnessSync/backend/src/models/__init__.py +++ b/FitnessSync/backend/src/models/__init__.py @@ -14,4 +14,5 @@ from .health_state import HealthSyncState from .scheduled_job import ScheduledJob from .bike_setup import BikeSetup from .segment import Segment -from .segment_effort import SegmentEffort \ No newline at end of file +from .segment_effort import SegmentEffort +from .stream import ActivityStream \ No newline at end of file diff --git a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc index f006722..b5ead78 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc and b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc index 036fe0c..c7e9689 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc index a15157a..178207f 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc and b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc index a2963f8..67fab09 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/segment.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/segment.cpython-311.pyc index f62aee7..964d5ee 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/segment.cpython-311.pyc and b/FitnessSync/backend/src/models/__pycache__/segment.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/segment.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/segment.cpython-313.pyc index ee58add..4c4748f 100644 Binary files a/FitnessSync/backend/src/models/__pycache__/segment.cpython-313.pyc and b/FitnessSync/backend/src/models/__pycache__/segment.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/stream.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/stream.cpython-311.pyc new file mode 100644 index 0000000..c14f566 Binary files /dev/null and b/FitnessSync/backend/src/models/__pycache__/stream.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/models/__pycache__/stream.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/stream.cpython-313.pyc new file mode 100644 index 0000000..e9a204d Binary files /dev/null and b/FitnessSync/backend/src/models/__pycache__/stream.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/models/activity.py b/FitnessSync/backend/src/models/activity.py index 5f6aea0..c35ffc3 100644 --- a/FitnessSync/backend/src/models/activity.py +++ b/FitnessSync/backend/src/models/activity.py @@ -73,4 +73,7 @@ class Activity(Base): bike_setup_id = Column(Integer, ForeignKey("bike_setups.id"), nullable=True) bike_match_confidence = Column(Float, nullable=True) # 0.0 to 1.0 score of match confidence - bike_setup = relationship("BikeSetup") \ No newline at end of file + bike_setup = relationship("BikeSetup") + + # Relationship to streams + streams = relationship("ActivityStream", back_populates="activity", uselist=False) \ No newline at end of file diff --git a/FitnessSync/backend/src/models/segment.py b/FitnessSync/backend/src/models/segment.py index 4e603d1..75b8256 100644 --- a/FitnessSync/backend/src/models/segment.py +++ b/FitnessSync/backend/src/models/segment.py @@ -21,3 +21,7 @@ class Segment(Base): activity_type = Column(String, nullable=False) # 'cycling', 'running' created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # PostGIS Geometry + from geoalchemy2 import Geometry + geom = Column(Geometry('LINESTRING', srid=4326), index=True) diff --git a/FitnessSync/backend/src/models/stream.py b/FitnessSync/backend/src/models/stream.py new file mode 100644 index 0000000..4c016d4 --- /dev/null +++ b/FitnessSync/backend/src/models/stream.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, ForeignKey, Float, Boolean, ARRAY +from sqlalchemy.orm import relationship +from geoalchemy2 import Geometry +from .base import Base + +class ActivityStream(Base): + __tablename__ = "activity_streams" + + id = Column(Integer, primary_key=True, index=True) + activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True) + + # Time Series Data (Arrays) + time_offset = Column(ARRAY(Integer)) # seconds from start + latitude = Column(ARRAY(Float)) + longitude = Column(ARRAY(Float)) + elevation = Column(ARRAY(Float)) + heart_rate = Column(ARRAY(Integer)) + power = Column(ARRAY(Integer)) + cadence = Column(ARRAY(Integer)) + speed = Column(ARRAY(Float)) + distance = Column(ARRAY(Float)) + temperature = Column(ARRAY(Float)) + moving = Column(ARRAY(Boolean)) + grade_smooth = Column(ARRAY(Float)) + + # Derived Spatial Data + # SRID 4326 = WGS 84 (Lat/Lon) + geom = Column(Geometry('LINESTRING', srid=4326), index=True) + + activity = relationship("Activity", back_populates="streams") diff --git a/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc b/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc index 4af798d..eedc711 100644 Binary files a/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc and b/FitnessSync/backend/src/schemas/__pycache__/discovery.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/schemas/discovery.py b/FitnessSync/backend/src/schemas/discovery.py index 0cf08d7..5396c5c 100644 --- a/FitnessSync/backend/src/schemas/discovery.py +++ b/FitnessSync/backend/src/schemas/discovery.py @@ -13,6 +13,10 @@ class DiscoveryFilter(BaseModel): class SingleDiscoveryRequest(BaseModel): activity_id: int + pause_threshold: float = 10.0 + rdp_epsilon: float = 10.0 + turn_threshold: float = 60.0 + min_length: float = 100.0 class CandidateSegmentSchema(BaseModel): diff --git a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-311.pyc index 8114b9f..2ad13f1 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc index 588349e..75a67c8 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/bike_matching.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/discovery.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/discovery.cpython-311.pyc index bb83f90..60de270 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/discovery.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/discovery.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc index a0e99d3..f55f0e5 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/discovery.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc index 8a90324..384750f 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc index 28af5fa..b862183 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc index 6ba264c..a390792 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/power_estimator.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc index bbb986b..a387bbb 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc index 675dca3..4435d33 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc index 845e2eb..4d1a3f5 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc and b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-313.pyc index 9a6ccec..db3767c 100644 Binary files a/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-313.pyc and b/FitnessSync/backend/src/services/__pycache__/segment_matcher.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/bike_matching.py b/FitnessSync/backend/src/services/bike_matching.py index 8d57179..2626dce 100644 --- a/FitnessSync/backend/src/services/bike_matching.py +++ b/FitnessSync/backend/src/services/bike_matching.py @@ -76,6 +76,8 @@ def calculate_observed_ratio(speed_mps: float, cadence_rpm: float) -> float: """ if not cadence_rpm or cadence_rpm == 0: return 0.0 + if not speed_mps: + return 0.0 return (speed_mps * 60) / (cadence_rpm * WHEEL_CIRCUMFERENCE_M) def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetup]: @@ -112,7 +114,23 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu observed_ratio = 0.0 # helper to check if we can use streams - if activity.file_content: + observed_ratio = 0.0 + + # helper to check if we can use streams + speeds = [] + cadences = [] + + # 1. Use DB Arrays if available (Fastest) + if activity.streams: + speeds = activity.streams.speed or [] + cadences = activity.streams.cadence or [] + + if speeds and cadences and len(speeds) > 0: + observed_ratio = calculate_ratio_from_streams(speeds, cadences) + logger.debug(f"Smart Match Ratio (DB) for {activity.id}: {observed_ratio:.2f}") + + # 2. Fallback to File Parsing if DB Arrays missing but file exists + if observed_ratio == 0 and activity.file_content: try: data = extract_activity_data(activity.file_content, activity.file_type) speeds = data.get('speed') or [] @@ -121,7 +139,7 @@ def match_activity_to_bike(db: Session, activity: Activity) -> Optional[BikeSetu # If explicit streams exist, use them if speeds and cadences and len(speeds) > 0: observed_ratio = calculate_ratio_from_streams(speeds, cadences) - logger.debug(f"Smart Match Ratio for {activity.id}: {observed_ratio:.2f}") + logger.debug(f"Smart Match Ratio (File) for {activity.id}: {observed_ratio:.2f}") except Exception as e: logger.warning(f"Failed to extract streams for Smart Matching activity {activity.id}: {e}") diff --git a/FitnessSync/backend/src/services/discovery.py b/FitnessSync/backend/src/services/discovery.py index de5cc8f..939d765 100644 --- a/FitnessSync/backend/src/services/discovery.py +++ b/FitnessSync/backend/src/services/discovery.py @@ -154,7 +154,11 @@ class SegmentDiscoveryService: return final_candidates, list(activity_paths.values()) - def analyze_single_activity(self, activity_id: int) -> List[CandidateSegment]: + def analyze_single_activity(self, activity_id: int, + pause_threshold: float = 10.0, + rdp_epsilon: float = 10.0, + turn_threshold: float = 60.0, + min_length: float = 100.0) -> List[CandidateSegment]: act = self.db.query(Activity).filter(Activity.id == activity_id).first() # Fallback to Garmin ID if not found by primary key @@ -205,7 +209,7 @@ class SegmentDiscoveryService: t2 = aligned_ts[i] diff = (t2 - t1).total_seconds() - if diff > 10.0: + if diff > pause_threshold: # Pause detected, split if i - seg_start > 5: sub_segments_indices.append([seg_start, i]) # i is exclusive? @@ -222,8 +226,8 @@ class SegmentDiscoveryService: segment_points = aligned_points[start_idx:end_idx] # Get RDP simplified INDICES (relative to segment_points start) - # Use epsilon=10.0m for robust major turn detection - rdp_indices = ramer_douglas_peucker_indices(segment_points, 10.0) + # Use epsilon from params + rdp_indices = ramer_douglas_peucker_indices(segment_points, rdp_epsilon) # Check turns at RDP vertices split_points_relative = [] @@ -245,7 +249,7 @@ class SegmentDiscoveryService: diff = abs(bearing - last_bearing) if diff > 180: diff = 360 - diff - if diff > 60: + if diff > turn_threshold: # Turn detected at vertex k-1 (idx1) # Convert relative idx1 to split point split_points_relative.append(idx1) @@ -276,7 +280,7 @@ class SegmentDiscoveryService: candidates = [] for path in final_segments: d = self._calculate_path_length(path) - if d > 100: # Min 100m + if d > min_length: # Min length parameter # Simple decimation for display simplified = self._decimate_points(path, min_dist=10.0) cand = CandidateSegment(simplified, 1, [activity_id]) diff --git a/FitnessSync/backend/src/services/parsers.py b/FitnessSync/backend/src/services/parsers.py index 4131ed9..8e59246 100644 --- a/FitnessSync/backend/src/services/parsers.py +++ b/FitnessSync/backend/src/services/parsers.py @@ -135,6 +135,80 @@ def _extract_data_from_fit(file_content: bytes) -> Dict[str, List[Any]]: logger.error(f"Error parsing FIT file: {e}") return data +def parse_fit_to_streams(file_content: bytes) -> Dict[str, Any]: + """ + Parses FIT file into ActivityStream compatible dictionary including arrays and WKT geometry. + """ + streams = { + 'time_offset': [], 'latitude': [], 'longitude': [], 'elevation': [], + 'heart_rate': [], 'power': [], 'cadence': [], 'speed': [], + 'distance': [], 'temperature': [], 'moving': [], 'grade_smooth': [] + } + + start_time = None + points_for_geom = [] + + try: + with io.BytesIO(file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record': + # Timestamp + ts = frame.get_value('timestamp') + if not start_time and ts: + start_time = ts + + t_offset = int((ts - start_time).total_seconds()) if ts and start_time else None + + # Helpers + def get_val(keys): + for k in keys: + if frame.has_field(k): return frame.get_value(k) + return None + + lat_sc = frame.get_value('position_lat') + lon_sc = frame.get_value('position_long') + lat = None + lon = None + if lat_sc is not None and lon_sc is not None: + lat = lat_sc * (180.0 / 2**31) + lon = lon_sc * (180.0 / 2**31) + points_for_geom.append(f"{lon} {lat}") + + ele = get_val(['enhanced_altitude', 'altitude']) + hr = get_val(['heart_rate']) + pwr = get_val(['power']) + cad = get_val(['cadence']) + spd = get_val(['enhanced_speed', 'speed']) + dist = get_val(['distance']) + temp = get_val(['temperature']) + + streams['time_offset'].append(t_offset) + streams['latitude'].append(lat) + streams['longitude'].append(lon) + streams['elevation'].append(ele) + streams['heart_rate'].append(hr) + streams['power'].append(pwr) + streams['cadence'].append(cad) + streams['speed'].append(spd) + streams['distance'].append(dist) + streams['temperature'].append(temp) + streams['moving'].append(None) # TODO: Logic for moving? + streams['grade_smooth'].append(None) # TODO: Logic for grade? + + except Exception as e: + logger.error(f"Error parsing FIT to streams: {e}") + return {} + + # Construct Geometry WKT + # WKT: LINESTRING(lon lat, lon lat, ...) + if points_for_geom: + streams['geom'] = f"SRID=4326;LINESTRING({', '.join(points_for_geom)})" + else: + streams['geom'] = None + + return streams + def _extract_points_from_fit(file_content: bytes) -> List[List[float]]: # Deprecated internal use, redirected return _extract_data_from_fit(file_content)['points'] diff --git a/FitnessSync/backend/src/services/power_estimator.py b/FitnessSync/backend/src/services/power_estimator.py index 248d679..4b37de4 100644 --- a/FitnessSync/backend/src/services/power_estimator.py +++ b/FitnessSync/backend/src/services/power_estimator.py @@ -42,15 +42,34 @@ class PowerEstimatorService: total_mass = rider_weight + bike_weight # 2. Extract Data - data = extracted_data - if not data: - if not activity.file_content: - return [] - data = extract_activity_data(activity.file_content, activity.file_type) + timestamps = [] + speeds = [] + elevations = [] - timestamps = data.get('timestamps') - speeds = data.get('enhanced_speed') or data.get('speed') - elevations = data.get('enhanced_altitude') or data.get('altitude') + # Try to use ActivityStreams first + if activity.streams: + # Reconstruct timestamps from start_time + time_offset + if activity.streams.time_offset: + base_time = activity.start_time + # Handle tzinfo if necessary - simply add seconds + timestamps = [base_time + __import__('datetime').timedelta(seconds=t) for t in activity.streams.time_offset] + + speeds = activity.streams.speed or [] + elevations = activity.streams.elevation or [] + + # Fallback to file parsing if streams are empty or missing + if not speeds and extracted_data: + data = extracted_data + timestamps = data.get('timestamps', []) + speeds = data.get('enhanced_speed') or data.get('speed', []) + elevations = data.get('enhanced_altitude') or data.get('altitude', []) + + if not speeds and not timestamps and activity.file_content: + # Last resort: parse file content on demand + data = extract_activity_data(activity.file_content, activity.file_type) + timestamps = data.get('timestamps', []) + speeds = data.get('enhanced_speed') or data.get('speed', []) + elevations = data.get('enhanced_altitude') or data.get('altitude', []) if not speeds or not len(speeds) > 0: return [] @@ -58,10 +77,10 @@ class PowerEstimatorService: # Generate Power Stream power_stream = [] - for i in range(len(timestamps)): - if i >= len(speeds): - break - + # Ensure we don't go out of bounds if arrays are different lengths (shouldn't happen in valid FIT) + min_len = min(len(timestamps), len(speeds)) + + for i in range(min_len): t = timestamps[i] v = speeds[i] # m/s @@ -74,12 +93,17 @@ class PowerEstimatorService: grade = 0.0 accel = 0.0 - if i > 0 and i < len(speeds) - 1: + # Use a slightly wider window for smoothing grade if using raw data + # But the logic below is a basic central difference + + if i > 0 and i < min_len - 1: # Central difference if i+1 < len(timestamps) and i-1 >= 0: d_t = (timestamps[i+1] - timestamps[i-1]).total_seconds() if d_t > 0: - d_v = (speeds[i+1] - speeds[i-1]) # acc + v_next = speeds[i+1] if speeds[i+1] is not None else v + v_prev = speeds[i-1] if speeds[i-1] is not None else v + d_v = (v_next - v_prev) # acc curr_ele = elevations[i] if elevations and i < len(elevations) and elevations[i] is not None else 0 next_ele = elevations[i+1] if elevations and i+1 < len(elevations) and elevations[i+1] is not None else curr_ele diff --git a/FitnessSync/backend/src/services/scheduler.py b/FitnessSync/backend/src/services/scheduler.py index 4e6a9d5..39b3065 100644 --- a/FitnessSync/backend/src/services/scheduler.py +++ b/FitnessSync/backend/src/services/scheduler.py @@ -9,19 +9,17 @@ from ..services.postgresql_manager import PostgreSQLManager from ..models.scheduled_job import ScheduledJob from ..services.job_manager import job_manager from ..utils.config import config +# ... imports ... from ..tasks.definitions import ( run_activity_sync_task, - run_metrics_sync_task, - run_health_scan_job, run_fitbit_sync_job, run_garmin_upload_job, run_health_sync_job, - run_garmin_upload_job, - run_health_sync_job, run_activity_backfill_job, run_reparse_local_files_job, run_estimate_power_job, - run_segment_matching_task + run_segment_matching_task, + run_bike_matching_job ) logger = logging.getLogger(__name__) @@ -35,16 +33,14 @@ class SchedulerService: # Map job_type string to (function, default_params) self.TASK_MAP = { 'activity_sync': run_activity_sync_task, - 'metrics_sync': run_metrics_sync_task, - 'health_scan': run_health_scan_job, + 'health_sync': run_health_sync_job, 'fitbit_weight_sync': run_fitbit_sync_job, 'garmin_weight_upload': run_garmin_upload_job, - 'health_sync_pending': run_health_sync_job, - 'health_sync_pending': run_health_sync_job, 'activity_backfill_full': run_activity_backfill_job, 'reparse_local_files': run_reparse_local_files_job, 'estimate_power': run_estimate_power_job, - 'segment_matching': run_segment_matching_task + 'segment_matching': run_segment_matching_task, + 'bike_matching': run_bike_matching_job } def start(self): @@ -64,78 +60,127 @@ class SchedulerService: if self._thread: self._thread.join(timeout=5) + def _create_default_jobs(self, session): + """Helper to define and create the default job set.""" + defaults = [ + # Scheduled (Active) + { + "job_type": "activity_sync", + "name": "Activity Sync (Recent)", + "interval": 60, + "params": {"days_back": 14}, + "enabled": True + }, + { + "job_type": "health_sync", + "name": "Health Sync (Recent)", + "interval": 60, + "params": {"days_back": 14}, + "enabled": True + }, + { + "job_type": "fitbit_weight_sync", + "name": "Fitbit Weight Sync", + "interval": 360, + "params": {"days_back": 30}, + "enabled": True + }, + { + "job_type": "garmin_weight_upload", + "name": "Garmin Weight Upload", + "interval": 360, + "params": {"limit": 50}, + "enabled": True + }, + { + "job_type": "bike_matching", + "name": "Match Bikes", + "interval": 60, + "params": {}, + "enabled": True + }, + + # Manual / On-Demand + { + "job_type": "activity_backfill_full", + "name": "Full Activity History Backfill (20y)", + "interval": 0, + "params": {"days_back": 7200}, + "enabled": False + }, + { + "job_type": "health_sync", + "name": "Full Health History Sync (20y)", + "interval": 0, + "params": {"days_back": 7200}, + "enabled": False + }, + { + "job_type": "fitbit_weight_sync", + "name": "Full Fitbit Weight Sync (20y)", + "interval": 0, + "params": {"days_back": 7200}, + "enabled": False + }, + { + "job_type": "segment_matching", + "name": "Run Segment Matching (All)", + "interval": 0, + "params": {}, + "enabled": False + }, + { + "job_type": "estimate_power", + "name": "Estimate Power (All)", + "interval": 0, + "params": {}, + "enabled": False + }, + { + "job_type": "reparse_local_files", + "name": "Reparse Local FIT Files", + "interval": 0, + "params": {}, + "enabled": False + } + ] + + for job_def in defaults: + existing = session.query(ScheduledJob).filter_by(name=job_def["name"], job_type=job_def["job_type"]).first() + if not existing: + logger.info(f"Creating default schedule: {job_def['name']}") + new_job = ScheduledJob( + job_type=job_def["job_type"], + name=job_def["name"], + interval_minutes=job_def["interval"], + params=json.dumps(job_def["params"]), + enabled=job_def["enabled"] + ) + session.add(new_job) + def ensure_defaults(self): - """Ensure default schedules exist.""" + """Ensure default schedules exist (additive only).""" with self.db_manager.get_db_session() as session: try: - # Default 1: Fitbit Weight Sync (30 days) every 6 hours - job_type = 'fitbit_weight_sync' - name = 'Fitbit Weight Sync (30d)' - - existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() - if not existing: - logger.info(f"Creating default schedule: {name}") - new_job = ScheduledJob( - job_type=job_type, - name=name, - interval_minutes=360, # 6 hours - params=json.dumps({"days_back": 30}), - enabled=True - ) - session.add(new_job) - session.commit() - - # Default 2: Manual Job - Reparse Local FIT Files - job_type = 'reparse_local_files' - name = 'Reparse Local FIT Files' - existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() - if not existing: - logger.info(f"Creating default schedule: {name}") - new_job = ScheduledJob( - job_type=job_type, - name=name, - interval_minutes=0, # Manual only - params=json.dumps({}), - enabled=False # Disabled by default, visible in UI for manual trigger - ) - session.add(new_job) - session.commit() - - # Default 3: Manual Job - Estimate Power - job_type = 'estimate_power' - name = 'Estimate Power (All)' - existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() - if not existing: - logger.info(f"Creating default schedule: {name}") - new_job = ScheduledJob( - job_type=job_type, - name=name, - interval_minutes=0, # Manual only - params=json.dumps({}), - enabled=False # Disabled by default, visible in UI for manual trigger - ) - session.add(new_job) - session.commit() - - # Default 4: Manual Job - Segment Matching - job_type = 'segment_matching' - name = 'Run Segment Matching (All)' - existing = session.query(ScheduledJob).filter_by(job_type=job_type).first() - if not existing: - logger.info(f"Creating default schedule: {name}") - new_job = ScheduledJob( - job_type=job_type, - name=name, - interval_minutes=0, # Manual only - params=json.dumps({}), - enabled=False # Disabled by default - ) - session.add(new_job) - session.commit() - + self._create_default_jobs(session) + session.commit() except Exception as e: logger.error(f"Error checking default schedules: {e}") + def reset_defaults(self): + """Wipe all scheduled jobs and recreate defaults.""" + with self.db_manager.get_db_session() as session: + try: + logger.warning("Resetting all scheduled jobs to defaults...") + session.query(ScheduledJob).delete() + self._create_default_jobs(session) + session.commit() + return True + except Exception as e: + logger.error(f"Error resetting defaults: {e}") + session.rollback() + raise e + def _run_loop(self): logger.info("Scheduler loop started.") while not self._stop_event.is_set(): @@ -154,10 +199,6 @@ class SchedulerService: now = datetime.now() # Find due jobs - # due if enabled AND (next_run <= now OR next_run is NULL) - # also, if last_run is NULL, we might want to run immediately or schedule for later? - # Let's run immediately if next_run is NULL (freshly created). - jobs = session.query(ScheduledJob).filter( ScheduledJob.enabled == True, or_( @@ -167,9 +208,6 @@ class SchedulerService: ).all() for job in jobs: - # Double check locking? For now, simple single-instance app is assumed. - # If we had multiple workers, we'd need 'FOR UPDATE SKIP LOCKED' or similar. - self._execute_job(session, job) session.commit() @@ -180,8 +218,6 @@ class SchedulerService: task_func = self.TASK_MAP.get(job_record.job_type) if not task_func: logger.error(f"Unknown job type: {job_record.job_type}") - # Disable to prevent spam loop? - # job_record.enabled = False return # Parse params @@ -195,11 +231,6 @@ class SchedulerService: # Create Job via Manager job_id = job_manager.create_job(f"{job_record.name} (Scheduled)") - # Launch task in thread (don't block scheduler loop) - # We pass self.db_manager.get_db_session factory - # Note: We must duplicate the factory access b/c `run_*` definitions use `with factory() as db:` - # passing self.db_manager.get_db_session is correct. - t = threading.Thread( target=task_func, kwargs={ @@ -213,7 +244,7 @@ class SchedulerService: # Update next_run job_record.last_run = datetime.now() job_record.next_run = datetime.now() + timedelta(minutes=job_record.interval_minutes) - # session commit happens in caller loop + def trigger_job(self, job_id: int) -> bool: """Manually trigger a scheduled job immediately.""" diff --git a/FitnessSync/backend/src/services/segment_matcher.py b/FitnessSync/backend/src/services/segment_matcher.py index ccbcd00..441c365 100644 --- a/FitnessSync/backend/src/services/segment_matcher.py +++ b/FitnessSync/backend/src/services/segment_matcher.py @@ -1,8 +1,9 @@ from typing import List, Optional, Tuple from datetime import timedelta import logging -from sqlalchemy.orm import Session -from sqlalchemy import text # correct import +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import text, or_, func +from geoalchemy2.functions import ST_Intersects import json from ..models.activity import Activity @@ -19,20 +20,14 @@ class SegmentMatcher: def match_activity(self, activity: Activity, points: List[List[float]], min_created_at=None) -> List[SegmentEffort]: """ - Check if the activity matches any known segments. - points: List of [lon, lat] + Check if the activity matches any known segments using PostGIS. """ if not points or len(points) < 2: return [] - - # 1. Calculate bounds of activity for fast filtering - act_bounds = calculate_bounds(points) # [min_lat, min_lon, max_lat, max_lon] - - # 2. Query potential segments from DB (simple bbox overlap) - # We'll just fetch all segments for now or use a generous overlap check - # if we had PostGIS. Since we use JSON bounds, we can't easily query overlap in SQL - # without special extensions. We'll fetch all and filter in Python. - # Ideally, we'd use PostGIS geometry types. + + # Optimize: Use ActivityStream geometry if available + # But we might just have the activity object and points passed in triggered by sync. + # We can construct WKT from points for the query # Normalize activity type act_type = activity.activity_type @@ -41,47 +36,165 @@ class SegmentMatcher: elif act_type in ['trail_running', 'treadmill_running']: act_type = 'running' - query = self.db.query(Segment).filter( - (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) - ) + from geoalchemy2.functions import ST_Intersects + from sqlalchemy import func + # Construct simplified lineage for the query (or use the one in ActivityStream if present) + # If we have ActivityStream, use it. + from ..models.stream import ActivityStream + act_stream = self.db.query(ActivityStream).filter_by(activity_id=activity.id).first() + + candidates = [] + if act_stream and act_stream.geom is not None: + # Use DB geometry + logger.info(f"Segment Match: Using DB Geometry for Activity {activity.id}") + query = self.db.query(Segment).filter( + (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) + ).filter( + ST_Intersects(Segment.geom, act_stream.geom) + ) + else: + # Fallback (or if ActivityStream not yet populated, though sync should have done it) + # Create WKT from points + logger.info(f"Segment Match: Using Points Geometry for Activity {activity.id}") + wkt_points = [f"{p[0]} {p[1]}" for p in points if len(p)>=2] + wkt = f"SRID=4326;LINESTRING({', '.join(wkt_points)})" + + query = self.db.query(Segment).filter( + (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) + ).filter( + ST_Intersects(Segment.geom, func.ST_GeomFromText(wkt, 4326)) + ) + if min_created_at: query = query.filter(Segment.created_at >= min_created_at) - segments = query.all() + candidates = query.all() matched_efforts = [] + logger.info(f"Segment Match: Found {len(candidates)} candidate segments intersecting Activity {activity.id} path.") - print(f"DEBUG SEGMENT MATCH: Checking {len(segments)} segments against Activity {activity.id} Bounds={act_bounds}") - - for segment in segments: - # print(f"DEBUG: Checking Segment {segment.name} Bounds={segment.bounds}") - seg_bounds = json.loads(segment.bounds) if isinstance(segment.bounds, str) else segment.bounds - - if self._check_bounds_overlap(act_bounds, seg_bounds): - try: - seg_points = json.loads(segment.points) if isinstance(segment.points, str) else segment.points - print(f"DEBUG: Overlap OK. Matching {segment.name}...") - indices = self._match_segment(segment, seg_points, activity, points) - if indices: - start_idx, end_idx = indices - print(f"DEBUG: MATCH FOUND for {segment.name}! Indices {start_idx}-{end_idx}") - effort = self._create_effort(segment, activity, start_idx, end_idx) - if effort: - matched_efforts.append(effort) - except Exception as e: - logger.error(f"Error matching segment {segment.id}: {e}") + for segment in candidates: + # Proceed with detailed matching (Python logic is still good for precise start/end) + # PostGIS intersect just guarantees we cross paths, but logic needs to check direction/completeness etc. + # We reuse existing _match_segment + try: + seg_points = json.loads(segment.points) if isinstance(segment.points, str) else segment.points + indices = self._match_segment(segment, seg_points, activity, points) + if indices: + start_idx, end_idx = indices + logger.info(f"MATCH CONFIRMED for {segment.name}! Indices {start_idx}-{end_idx}") + effort = self._create_effort(segment, activity, start_idx, end_idx) + if effort: + matched_efforts.append(effort) + except Exception as e: + logger.error(f"Error matching segment {segment.id}: {e}") if matched_efforts: logger.info(f"Activity {activity.id} matched {len(matched_efforts)} segments.") - print(f"DEBUG SEGMENT MATCH: Matched {len(matched_efforts)} segments for Activity {activity.id}. Saving...") self.db.add_all(matched_efforts) self.db.commit() - else: - print(f"DEBUG SEGMENT MATCH: No segments matched for Activity {activity.id}") - + return matched_efforts + def scan_segment(self, segment: Segment, overwrite: bool = True) -> int: + """ + Inverted Index Scan: Find all activities that match this specific segment. + Uses PostGIS 'ST_Intersects' on ActivityStream.geom to find candidates efficiently. + """ + from ..models.stream import ActivityStream + from geoalchemy2.functions import ST_Intersects + from sqlalchemy import or_ + + # 1. Broad Phase: Find Spatially Intersecting Activities + # We need activities that have streams with geometry + query = self.db.query(Activity).join(ActivityStream, Activity.id == ActivityStream.activity_id) + + # Optimization: Filter out already matched activities if not overwriting + if not overwrite: + existing_subquery = self.db.query(SegmentEffort.activity_id).filter(SegmentEffort.segment_id == segment.id) + query = query.filter(Activity.id.notin_(existing_subquery)) + + # Filter by Type + # Map segment type to activity types (inverse of what we do in match_activity) + if segment.activity_type == 'running': + query = query.filter(or_( + Activity.activity_type.ilike('running'), + Activity.activity_type.ilike('trail_running'), + Activity.activity_type.ilike('treadmill_running'), # unlikely to have GPS but safe + Activity.activity_type.ilike('walking'), + Activity.activity_type.ilike('hiking') + )) + elif segment.activity_type == 'cycling': + query = query.filter(or_( + Activity.activity_type.ilike('%cycling%'), + Activity.activity_type.ilike('%road_biking%'), + Activity.activity_type.ilike('%mountain%'), + Activity.activity_type.ilike('%mtb%'), + Activity.activity_type.ilike('%cyclocross%') + )) + + # Spatial Filter + # Use segment.geom (which must be set and valid SRID 4326) + query = query.filter(ST_Intersects(ActivityStream.geom, segment.geom)) + + # Eager load streams to avoid lazy loading issues/performance penalty + query = query.options(joinedload(Activity.streams)) + + candidates = query.all() + logger.info(f"Segment Scan: Found {len(candidates)} candidate activities for Segment {segment.name} ({segment.id})") + + matches_found = 0 + + for activity in candidates: + try: + # 2. Detailed Match + points = [] + # Check for streams object availability + if activity.streams: + # Zip arrays + lats = activity.streams.latitude + lons = activity.streams.longitude + if lats and lons: + # points = [[lon, lat] for lat, lon in zip(lats, lons) if lat is not None and lon is not None] + # Ensure same length + points = [[lon, lat] for lat, lon in zip(lats, lons) if lat is not None and lon is not None] + + if not points and activity.file_content: + # Fallback to parsing + from ..services.parsers import extract_points_from_file + points = extract_points_from_file(activity.file_content, activity.file_type) + + if not points: + print(f"DEBUG: Activity {activity.id} - No points found (streams={bool(activity.streams)}).") + continue + + # Match Logic + # Reuse _match_segment + seg_points = json.loads(segment.points) if isinstance(segment.points, str) else segment.points + + indices = self._match_segment(segment, seg_points, activity, points) + + if indices: + start_idx, end_idx = indices + + # Check if effort already exists? + existing = self.db.query(SegmentEffort).filter_by(segment_id=segment.id, activity_id=activity.id).first() + if existing: + self.db.delete(existing) + + effort = self._create_effort(segment, activity, start_idx, end_idx) + if effort: + self.db.add(effort) + matches_found += 1 + logger.info(f"MATCH: Activity {activity.id} matched Segment {segment.id}") + + except Exception as e: + logger.error(f"Error checking activity {activity.id} for segment {segment.id}: {e}") + + self.db.commit() + return matches_found + def _create_effort(self, segment, activity, start_idx, end_idx) -> Optional[SegmentEffort]: # Extract timestamps # Need to re-parse file to get timestamps? Or cached? diff --git a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc index 2bc71b1..127968c 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc index 925f346..4a26e71 100644 Binary files a/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc and b/FitnessSync/backend/src/services/sync/__pycache__/activity.cpython-313.pyc differ diff --git a/FitnessSync/backend/src/services/sync/activity.py b/FitnessSync/backend/src/services/sync/activity.py index b406352..b779165 100644 --- a/FitnessSync/backend/src/services/sync/activity.py +++ b/FitnessSync/backend/src/services/sync/activity.py @@ -10,6 +10,8 @@ from ...models.activity_state import GarminActivityState from ...models.sync_log import SyncLog from ...services.garmin.client import GarminClient from ...services.job_manager import job_manager +from ...services.parsers import parse_fit_to_streams +from ...models.stream import ActivityStream logger = logging.getLogger(__name__) @@ -263,6 +265,9 @@ class GarminActivitySync: # Backfill metrics from file content if needed self._backfill_metrics_from_file(activity) + + # Save Activity Streams to DB (PostGIS) + self._save_activity_streams(activity) self.db_session.flush() # Commit file changes so it's fresh @@ -451,6 +456,7 @@ class GarminActivitySync: try: self._backfill_metrics_from_file(activity) + self._save_activity_streams(activity) processed += 1 # Optimistically assume something might have updated, or we could track it. # Since _backfill checks for None, it only updates if missing. @@ -470,3 +476,37 @@ class GarminActivitySync: self.db_session.commit() return {"total": total_count, "processed": processed} + def _save_activity_streams(self, activity: Activity): + """ + Parse FIT content and save to activity_streams table. + """ + if not activity.file_content or activity.file_type != 'fit': + return + + try: + # Check if streams already exist? + existing = self.db_session.query(ActivityStream).filter_by(activity_id=activity.id).first() + if existing: + # Update or Skip? Let's overwrite/update + self.logger.info(f"Streams already exist for {activity.id}, updating...") + # Easier to delete and recreate or update columns? + # Update columns is better for ID stability but recreate is simpler. + # Let's recreate logic: Parse first. + pass + + data = parse_fit_to_streams(activity.file_content) + if not data: + return + + if existing: + for k, v in data.items(): + setattr(existing, k, v) + else: + stream = ActivityStream(activity_id=activity.id, **data) + self.db_session.add(stream) + + self.logger.info(f"Saved streams for Activity {activity.id}") + + except Exception as e: + self.logger.error(f"Error saving streams for {activity.id}: {e}") + diff --git a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc index 074f5c4..800575f 100644 Binary files a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc and b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc differ diff --git a/FitnessSync/backend/src/tasks/definitions.py b/FitnessSync/backend/src/tasks/definitions.py index 983e8e0..29a3557 100644 --- a/FitnessSync/backend/src/tasks/definitions.py +++ b/FitnessSync/backend/src/tasks/definitions.py @@ -24,40 +24,32 @@ def run_activity_sync_task(job_id: str, days_back: int, db_session_factory): logger.error(f"Background task failed: {e}") job_manager.fail_job(job_id, str(e)) -def run_metrics_sync_task(job_id: str, days_back: int, db_session_factory): - logger.info(f"Starting background metrics sync task {job_id}") - with db_session_factory() as session: - try: - load_and_verify_garth_session(session) - garmin_client = GarminClient() - sync_app = SyncApp(db_session=session, garmin_client=garmin_client) - sync_app.sync_health_metrics(days_back=days_back, job_id=job_id) - except Exception as e: - logger.error(f"Background task failed: {e}") - job_manager.fail_job(job_id, str(e)) - -def run_health_scan_job(job_id: str, days_back: int, db_session_factory): - """Background task wrapper for health scan""" +def run_health_sync_job(job_id: str, days_back: int, db_session_factory): + """ + Merged health sync job: Scan metadata gaps -> Sync pending details. + Replaces old metrics_sync and independent scan/pending jobs. + """ with db_session_factory() as db: try: + load_and_verify_garth_session(db) garmin_client = GarminClient() sync_app = SyncApp(db, garmin_client) - job_manager.update_job(job_id, status="running", progress=0) + # Step 1: Scan for gaps + job_manager.update_job(job_id, status="running", progress=0, message=f"Scanning health metrics gaps ({days_back} days)...") sync_app.scan_health_metrics(days_back=days_back) - job_manager.complete_job(job_id) - except Exception as e: - logger.error(f"Background task failed: {e}") - job_manager.fail_job(job_id, str(e)) - -def run_health_sync_job(job_id: str, limit: int, db_session_factory): - """Background task wrapper for health sync pending""" - with db_session_factory() as db: - try: - garmin_client = GarminClient() - sync_app = SyncApp(db, garmin_client) - sync_app.sync_pending_health_metrics(limit=limit, job_id=job_id) + # Step 2: Sync pending items + job_manager.update_job(job_id, status="running", progress=20, message="Syncing pending health details...") + + # Pass job_id for sub-progress updates + sync_app.sync_pending_health_metrics(limit=None, job_id=job_id) + + # Job completion is handled by sync_pending_health_metrics if job_id is passed? + # Checking code: yes line 390 calls complete_job. + # But wait, we passed job_id to it. + # If sub-function completes it, we shouldn't do anything else here. + # However, sync_pending_metrics assumes it OWNS the job. except Exception as e: logger.error(f"Health sync job failed: {e}") @@ -234,3 +226,19 @@ def run_segment_matching_task(job_id: str, db_session_factory, **kwargs): # Just call the job function directly, it handles everything run_segment_matching_job(job_id, db_session_factory=db_session_factory, **kwargs) +def run_bike_matching_job(job_id: str, db_session_factory): + """Background task wrapper for bike matching""" + with db_session_factory() as db: + try: + from ..services.bike_matching import run_matching_for_all + + job_manager.update_job(job_id, status="running", progress=0, message="Starting bike matching...") + + run_matching_for_all(db) + + job_manager.complete_job(job_id, result={"status": "completed"}) + + except Exception as e: + logger.error(f"Bike matching job failed: {e}") + job_manager.fail_job(job_id, str(e)) + diff --git a/FitnessSync/backend/src/utils/__pycache__/geo.cpython-313.pyc b/FitnessSync/backend/src/utils/__pycache__/geo.cpython-313.pyc index 46f9304..795b570 100644 Binary files a/FitnessSync/backend/src/utils/__pycache__/geo.cpython-313.pyc and b/FitnessSync/backend/src/utils/__pycache__/geo.cpython-313.pyc differ diff --git a/FitnessSync/backend/templates/activities.html b/FitnessSync/backend/templates/activities.html index f7fac0a..680ea74 100644 --- a/FitnessSync/backend/templates/activities.html +++ b/FitnessSync/backend/templates/activities.html @@ -6,28 +6,11 @@
- - - - - + +
-
- New: 0 - Updated: 0 - Synced: 0 -
+
@@ -419,15 +402,8 @@ document.getElementById('download-selected-btn').addEventListener('click', downloadSelected); document.getElementById('redownload-selected-btn').addEventListener('click', redownloadSelected); - document.getElementById('scan-30-btn').addEventListener('click', () => scanActivities(30)); - document.getElementById('scan-all-btn').addEventListener('click', () => scanActivities(3650)); - document.getElementById('sync-10-btn').addEventListener('click', () => syncPending(10)); - document.getElementById('sync-all-btn').addEventListener('click', () => syncPending(null)); - document.getElementById('sync-all-btn').addEventListener('click', () => syncPending(null)); - document.getElementById('match-bikes-btn').addEventListener('click', triggerBikeMatching); - updateSyncStatus(); }); // ... Helpers same as before ... @@ -617,17 +593,7 @@ loadActivities(); } - async function updateSyncStatus() { - try { - const res = await fetch('/api/activities/sync/status'); - if (res.ok) { - const data = await res.json(); - document.getElementById('status-new').textContent = `New: ${data.new || 0}`; - document.getElementById('status-updated').textContent = `Updated: ${data.updated || 0}`; - document.getElementById('status-synced').textContent = `Synced: ${data.synced || 0}`; - } - } catch (e) { } - } + async function scanActivities(daysBack) { showToast("Scanning...", `Starting scan...`, "info"); @@ -655,21 +621,7 @@ } catch (e) { showToast("Error", e.message, "error"); } } - async function triggerBikeMatching() { - showToast("Matching...", "Starting bike match process...", "info"); - try { - const res = await fetch('/api/bike-setups/match-all', { method: 'POST' }); - const data = await res.json(); - if (res.ok) { - showToast("Success", data.message, "success"); - loadActivities(); - } else { - throw new Error("Failed"); - } - } catch (e) { - showToast("Error", "Matching failed", "error"); - } - } + async function fetchBikeSetups() { try { diff --git a/FitnessSync/backend/templates/activity_view.html b/FitnessSync/backend/templates/activity_view.html index 17af5f0..5cf66dc 100644 --- a/FitnessSync/backend/templates/activity_view.html +++ b/FitnessSync/backend/templates/activity_view.html @@ -3,339 +3,397 @@ {% block head %} - - + + {% endblock %} {% block content %} -
-
-

Loading...

-

- - | - - | - # -

-
-
-
- - - - - - - - - - - - + +
+
+
+

Loading Activity...

+

+ | + | + ID: +

- - - - - - - Discovery - -
-
+
+ +
+ + + + +
- -
-
-
-
- -
-
-
-
- -
Distance
-

-

+
+ +
+ +
+
+ Route Map +
-
-
-
-
-
- -
Duration
-

-

-
-
-
-
-
-
- -
Avg HR
-

-

-
-
-
-
-
-
- -
Calories
-

-

-
-
-
-
- - -

Detailed Metrics

-
- -
-
-
-
Matched Segments
- -
-
-
- - - - - - - - - - - - - - -
SegmentTimeAwardsRank
Loading segments...
+
+
+
-
- -
-
-
Heart Rate
-
-
Average: - - bpm
-
Max: - bpm
+ +
+
Matched Segments
+
+
Loading segments...
-
- -
-
-
Speed / Pace
-
-
Avg Speed: - km/h
-
Max Speed: - - km/h
-
-
-
- - -
-
-
Power
-
-
Avg Power: - W
-
Max Power: - W
-
Norm Power: - W
-
VO2 Max: -
-
-
-
- - -
-
-
Elevation
-
-
Gain: - m -
-
Loss: - m -
-
-
-
- - -
-
-
Training Effect
-
-
Aerobic: - -
-
Anaerobic: -
-
TSS: -
-
-
-
- - -
-
-
Cadence
-
-
Avg: - -
-
Max: -
-
-
-
- - -
-
-
Respiration
-
-
Avg: - - br/min
-
Max: - br/min -
-
-
-
- - -
-
-
- Bike Setup - -
-
-
No Setup
-
-
-
-
- - -
-
-
-
- Activity Streams -
-
+ +
+
+ Activity Streams +
+
- +
- +
-
-
-
-

Cycling Workout Analysis

-

Toggle metrics to customize view

+ + +
+ +
+ +
+
+
+ + +
+ +
+
Overview
+
+
+
Distance
+
-
+
+
+
Duration
+
-
+
+
+
Avg HR
+
-
+
+
+
+ + +
+
Performance Metrics
+
+ +
+

Heart Rate

+
Average- bpm
+
Max- bpm
+
+ + +
+

Speed

+
Average- km/h
+
Max- km/h
+
+ + +
+

Power

+
Average- W
+
Max- W
+
Normalized- W
+
VO2 Max-
+
+ + +
+

Elevation

+
Gain- m
+
Loss- m
+
+ + +
+

Training Effect

+
Aerobic-
+
Anaerobic-
+
TSS-
+
+ + +
+

Cadence

+
Average- rpm
+
Max- rpm
+
+ + +
+

Respiration

+
Average- br/min
+
Max- br/min
+
+ + +
+
+

Bike Setup

+
-
-
- - -
- -
-
- - -
- -
- - -
- -
- - -
- +