From 362f4cb5aa3094fba99e9e7a6ecf5e637fc10dfb Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 13 Jan 2026 09:42:16 -0800 Subject: [PATCH] many updates --- .../backend/__pycache__/main.cpython-311.pyc | Bin 6112 -> 6242 bytes .../backend/__pycache__/main.cpython-313.pyc | Bin 5453 -> 5560 bytes .../09c17c0f0e9e_add_extended_fit_metrics.py | 58 ++ .../0c82944705e9_add_is_estimated_power.py | 32 ++ ...82ec_add_last_segment_scan_timestamp_v3.py | 30 + .../versions/52a16d820129_add_streams_json.py | 24 + ...df63_add_last_segment_scan_timestamp_v2.py | 30 + ...63c8db0_add_last_segment_scan_timestamp.py | 30 + ...e_add_extended_fit_metrics.cpython-311.pyc | Bin 0 -> 4879 bytes ...e_add_extended_fit_metrics.cpython-313.pyc | Bin 0 -> 4717 bytes ...5e9_add_is_estimated_power.cpython-311.pyc | Bin 0 -> 1818 bytes ..._segment_scan_timestamp_v3.cpython-311.pyc | Bin 0 -> 1133 bytes ...16d820129_add_streams_json.cpython-311.pyc | Bin 0 -> 1179 bytes ..._segment_scan_timestamp_v2.cpython-311.pyc | Bin 0 -> 1133 bytes ...ast_segment_scan_timestamp.cpython-311.pyc | Bin 0 -> 1127 bytes ...495f5e_add_segments_tables.cpython-313.pyc | Bin 0 -> 4622 bytes ...vg_temperature_to_activity.cpython-311.pyc | Bin 0 -> 2450 bytes ...vg_temperature_to_activity.cpython-313.pyc | Bin 0 -> 2236 bytes ...max_power_to_segmenteffort.cpython-311.pyc | Bin 0 -> 1460 bytes ...ast_segment_scan_timestamp.cpython-311.pyc | Bin 0 -> 1482 bytes ...add_segment_effort_metrics.cpython-311.pyc | Bin 0 -> 1592 bytes ...6af329e_add_avg_temperature_to_activity.py | 46 ++ ...223773cb_add_max_power_to_segmenteffort.py | 30 + ...b0ba015_add_last_segment_scan_timestamp.py | 30 + ...e9b8841a1234_add_segment_effort_metrics.py | 25 + .../backend/backfill_estimated_power.py | 18 + FitnessSync/backend/check_db_status.py | 22 + FitnessSync/backend/debug_activity.py | 77 +++ FitnessSync/backend/debug_estimated_power.py | 50 ++ FitnessSync/backend/inspect_fit_fields.py | 72 +++ FitnessSync/backend/main.py | 5 + FitnessSync/backend/requirements.txt | 1 + .../__pycache__/activities.cpython-311.pyc | Bin 46164 -> 48649 bytes .../__pycache__/activities.cpython-313.pyc | Bin 37182 -> 41982 bytes .../api/__pycache__/analysis.cpython-311.pyc | Bin 0 -> 12265 bytes .../__pycache__/scheduling.cpython-311.pyc | Bin 8314 -> 8785 bytes .../api/__pycache__/segments.cpython-311.pyc | Bin 18350 -> 20061 bytes FitnessSync/backend/src/api/activities.py | 178 +++++- FitnessSync/backend/src/api/analysis.py | 232 ++++++++ FitnessSync/backend/src/api/scheduling.py | 11 +- FitnessSync/backend/src/api/segments.py | 52 +- .../segment_matching_job.cpython-311.pyc | Bin 4244 -> 6760 bytes .../backend/src/jobs/segment_matching_job.py | 56 +- .../__pycache__/activity.cpython-311.pyc | Bin 3259 -> 4501 bytes .../__pycache__/activity.cpython-313.pyc | Bin 2440 -> 3242 bytes .../segment_effort.cpython-311.pyc | Bin 1726 -> 1973 bytes .../segment_effort.cpython-313.pyc | Bin 1412 -> 1579 bytes FitnessSync/backend/src/models/activity.py | 28 +- .../backend/src/models/segment_effort.py | 4 + .../__pycache__/parsers.cpython-311.pyc | Bin 9279 -> 11135 bytes .../__pycache__/parsers.cpython-313.pyc | Bin 8439 -> 9908 bytes .../power_estimator.cpython-311.pyc | Bin 5291 -> 6599 bytes .../__pycache__/scheduler.cpython-311.pyc | Bin 9428 -> 11443 bytes .../__pycache__/scheduler.cpython-313.pyc | Bin 8485 -> 9591 bytes .../segment_matcher.cpython-311.pyc | Bin 10336 -> 15333 bytes FitnessSync/backend/src/services/parsers.py | 128 +++-- .../backend/src/services/power_estimator.py | 210 +++---- FitnessSync/backend/src/services/scheduler.py | 62 +- .../backend/src/services/segment_matcher.py | 142 ++++- .../sync/__pycache__/activity.cpython-311.pyc | Bin 18515 -> 25617 bytes .../sync/__pycache__/activity.cpython-313.pyc | Bin 16839 -> 23935 bytes .../sync/__pycache__/weight.cpython-311.pyc | Bin 13178 -> 13238 bytes .../sync/__pycache__/weight.cpython-313.pyc | Bin 12350 -> 12407 bytes .../backend/src/services/sync/activity.py | 143 +++++ .../backend/src/services/sync/weight.py | 5 + .../__pycache__/definitions.cpython-311.pyc | Bin 11658 -> 15757 bytes FitnessSync/backend/src/tasks/definitions.py | 63 ++ FitnessSync/backend/src/utils/algorithms.py | 70 +++ FitnessSync/backend/templates/activities.html | 75 ++- .../backend/templates/activity_view.html | 537 +++++++++++++----- FitnessSync/backend/templates/discovery.html | 18 + FitnessSync/backend/templates/index.html | 49 +- FitnessSync/backend/templates/segments.html | 262 ++++++++- .../backend/test_segment_optimization.py | 87 +++ ...test_analysis.cpython-311-pytest-7.4.3.pyc | Bin 0 -> 9235 bytes FitnessSync/backend/tests/test_analysis.py | 49 ++ FitnessSync/backend/verify_filters.py | 50 ++ FitnessSync/backend/verify_fix.py | 86 +++ FitnessSync/backend/verify_jobs.py | 27 + FitnessSync/backend/verify_segment_power.py | 124 ++++ FitnessSync/docs/specs/fitbit-integration.md | 114 ++++ 81 files changed, 3106 insertions(+), 336 deletions(-) create mode 100644 FitnessSync/backend/alembic/versions/09c17c0f0e9e_add_extended_fit_metrics.py create mode 100644 FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py create mode 100644 FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py create mode 100644 FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py create mode 100644 FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py create mode 100644 FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/87cc6ed8df63_add_last_segment_scan_timestamp_v2.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/a9c00e495f5e_add_segments_tables.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/b43006af329e_add_avg_temperature_to_activity.cpython-313.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc create mode 100644 FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py create mode 100644 FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py create mode 100644 FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py create mode 100644 FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py create mode 100644 FitnessSync/backend/backfill_estimated_power.py create mode 100644 FitnessSync/backend/check_db_status.py create mode 100644 FitnessSync/backend/debug_activity.py create mode 100644 FitnessSync/backend/debug_estimated_power.py create mode 100644 FitnessSync/backend/inspect_fit_fields.py create mode 100644 FitnessSync/backend/src/api/__pycache__/analysis.cpython-311.pyc create mode 100644 FitnessSync/backend/src/api/analysis.py create mode 100644 FitnessSync/backend/src/utils/algorithms.py create mode 100644 FitnessSync/backend/test_segment_optimization.py create mode 100644 FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc create mode 100644 FitnessSync/backend/tests/test_analysis.py create mode 100644 FitnessSync/backend/verify_filters.py create mode 100644 FitnessSync/backend/verify_fix.py create mode 100644 FitnessSync/backend/verify_jobs.py create mode 100644 FitnessSync/backend/verify_segment_power.py create mode 100644 FitnessSync/docs/specs/fitbit-integration.md diff --git a/FitnessSync/backend/__pycache__/main.cpython-311.pyc b/FitnessSync/backend/__pycache__/main.cpython-311.pyc index 126f32fb490a80f3a8b164afd277f692bdcdb881..95f86ce6c2aab19ade83b7fd11f49c255ef580a4 100644 GIT binary patch delta 146 zcmaE$|Hy!MIWI340}vcGPR(33kyny&-bQsfR#v54gD8W^bxhKX29xKpD6%W$80H#9 z8G%KNCa1B=Gb(OwV|~UW#F3bnm{VDtS**!;i@7{CNz-_9kx&(*NRbQB&|BQaMag9_Gnv;<}SpiyN}U0>uF5L@5#g delta 129 zcmaE4@Iaq;IWI340}y=4PRw+l$ScVhwNYJ;mDM2EFv@Up9g{S(VV2J1d2C`Fj0`Cp z!3>%Tn@_Pm<6&biPfgM^+I&c;ijn^ocX3g&UQvE&NorAXk;~)&5h>wVpplF~T)dN+ ef#Cx)BO~LD$qgbRn None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('activities', sa.Column('total_work', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('intensity_factor', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('threshold_power', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('avg_left_pedal_smoothness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_right_pedal_smoothness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_left_torque_effectiveness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_right_torque_effectiveness', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('grit', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('flow', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_respiration_rate', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('max_respiration_rate', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_stress', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('avg_spo2', sa.Float(), nullable=True)) + op.add_column('activities', sa.Column('total_strokes', sa.Integer(), nullable=True)) + op.add_column('activities', sa.Column('avg_stroke_distance', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activities', 'avg_stroke_distance') + op.drop_column('activities', 'total_strokes') + op.drop_column('activities', 'avg_spo2') + op.drop_column('activities', 'avg_stress') + op.drop_column('activities', 'max_respiration_rate') + op.drop_column('activities', 'avg_respiration_rate') + op.drop_column('activities', 'flow') + op.drop_column('activities', 'grit') + op.drop_column('activities', 'avg_right_torque_effectiveness') + op.drop_column('activities', 'avg_left_torque_effectiveness') + op.drop_column('activities', 'avg_right_pedal_smoothness') + op.drop_column('activities', 'avg_left_pedal_smoothness') + op.drop_column('activities', 'threshold_power') + op.drop_column('activities', 'intensity_factor') + op.drop_column('activities', 'total_work') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py b/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py new file mode 100644 index 0000000..6377f54 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/0c82944705e9_add_is_estimated_power.py @@ -0,0 +1,32 @@ +"""Add is_estimated_power + +Revision ID: 0c82944705e9 +Revises: 09c17c0f0e9e +Create Date: 2026-01-12 17:52:22.138566 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0c82944705e9' +down_revision: Union[str, None] = '09c17c0f0e9e' +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.add_column('activities', sa.Column('is_estimated_power', sa.Boolean(), nullable=True)) + op.drop_column('activities', 'avg_left_pedal_smoothness') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('activities', sa.Column('avg_left_pedal_smoothness', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.drop_column('activities', 'is_estimated_power') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py b/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py new file mode 100644 index 0000000..d2317b3 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/1136125782ec_add_last_segment_scan_timestamp_v3.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp_v3 + +Revision ID: 1136125782ec +Revises: 87cc6ed8df63 +Create Date: 2026-01-12 18:52:58.854314 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1136125782ec' +down_revision: Union[str, None] = '87cc6ed8df63' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py b/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py new file mode 100644 index 0000000..80bc0fa --- /dev/null +++ b/FitnessSync/backend/alembic/versions/52a16d820129_add_streams_json.py @@ -0,0 +1,24 @@ +"""add streams_json to activity + +Revision ID: 52a16d820129 +Revises: cc3b223773cb +Create Date: 2026-01-13 09:40:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '52a16d820129' +down_revision = 'e9b8841a1234' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('activities', sa.Column('streams_json', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('activities', 'streams_json') diff --git a/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py b/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py new file mode 100644 index 0000000..a13d2af --- /dev/null +++ b/FitnessSync/backend/alembic/versions/87cc6ed8df63_add_last_segment_scan_timestamp_v2.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp_v2 + +Revision ID: 87cc6ed8df63 +Revises: 8cc7963c8db0 +Create Date: 2026-01-12 18:52:47.577657 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '87cc6ed8df63' +down_revision: Union[str, None] = '8cc7963c8db0' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py b/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py new file mode 100644 index 0000000..4bbbc21 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/8cc7963c8db0_add_last_segment_scan_timestamp.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp + +Revision ID: 8cc7963c8db0 +Revises: 0c82944705e9 +Create Date: 2026-01-12 18:52:29.081798 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8cc7963c8db0' +down_revision: Union[str, None] = '0c82944705e9' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/09c17c0f0e9e_add_extended_fit_metrics.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b3a8a9f8e65c1e083928426540f692d39d69665 GIT binary patch literal 4879 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 literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/0c82944705e9_add_is_estimated_power.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d456dba5db99b5d016a033798cf50ffac70d765 GIT binary patch literal 1818 zcmb_c%}*Og6rcU@+FtxYXg->%N}Qxp>$KpHU;Tc*)7?ltfuxJStu_RWck|glCWXY9KDb&(s zODTnMs1zYlqGDwNcH(fUk4PknWO_$kuws_%hXP zqpVr_GO;|SV&W1Tdzxc2g%ZC?ZKIcIP$JMJ=!ZK1&yX*`&}k2QIu`s;cNL+B+`rYZ zcw)U<6^Ng;+wlRGr9F8E_U!id?T9dg-fqIlsJ%!~KcTlhF#@iXB2#t4H`h(yBpw?$ zP1=1(VZ^RlkOYgci~9PiW|0+Na|zZh&8s+$U$%+or4`DqT8$wQ^)Oi{DBVn#otV4bl1%+>&Ewx;EZmO%Cc!>)(Pds;$@DLr}6VoUvtRa zVBxB}N_9-=M{wpj=-wTGP4rS$p5Co}@vHbtG`K$bT-;axjP1vQZ@vps59+B04M2IR zAuk2;(qG_OtW5-0ChGF7hI}jN3jGj{GNAWb6f{o5;U2*MMR$u}D3;x$f(c<|FT4W< zv3pb$?@M~*ouo&LGy!*}X8>Lm0D$7CONr5Xn|JQRJBsU7-!W~2k_xeX+RxpS+_TLx z9pDQea5xLV`lsg?Z+|tTeSL3cYHnd}zDUn=+XW6hag_gNSd>4nHbW_xe+=C#1H8r1 z%yZh9Ed^vXm@U_5%Z=G`@c2m+p*iuc2*Z`4__~GvcJhefK9tpbA zx=LE4F^E}A_p`93VaL!kruZAK2{+?(Ql4cN?$ltXroA;ww~R8WY_vZ*%zY%!r!1*y zx@|kY?(<63G|J0@^4~q}||Kvz%8-{^dBQV!6CpexM?I&*n5vIToDfz;8ZgR9xi_*S*H@Y5%?ZyU+W0m_2Z zQT8BL=omiRzVY+m?`MN^gN@`+fC?aWR5%#QZ>n1}wQIG<+tQCuwsA0Uy%GC7KzWcl W$~P5Bm~5hxkdTB?zV=RNk?|jI^{j&c literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/1136125782ec_add_last_segment_scan_timestamp_v3.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b99a666fb7e3791678be49f77cddc4d8787fe81 GIT binary patch literal 1133 zcma)5y>HV%6hD6?aol{gg@O>Gjami*%9qomQB)zJmVu>0R2}eQIlgNfVLR^GDHN$v z28NCqkjlW&4f=nuu{ctP=%z|cY>~Qk;$5141SH_}?sxC`_wK!S@6KP6Nfp6RS4i`_ zh|mviTn>4poP7i35N#lgbc6*g+JYnMq9f^&z-`HvoroS8>arcxV>qHKBu-QuU51@F zrcV%w#Id|7-n)#AfK`U9tS2~2L=wMd!B@Jrz&AefP5y_kI`mE9#0ObV;S^57n#S@v z%BJ7%Kf^dOYWZ4av0PeI)t~(M zS&=F0WT#DBi?GOs3yHD<6I#FOZ2rwm>XHkGdxYKoXEUX5Imzx6Zk?Xg(f+oC2WbQU&MKV*{a-cG6gUlQ zya2w3P64nuZ^uP+9{D~eZgJo)S&=4SS<6ncm|@_SWf&|P?0HSt!Mi~DBWJPE8EJ%D zs=s5KwzW;1Jtq1l<)t#o4=76;hUvPkzzldJ41>mb2>u7r2@d>plo#<^jpxd;)52|= zJf#nSfpBX@jUJ#0O{`665ALJG_fuTlW}+ literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/52a16d820129_add_streams_json.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca364346842e39dd75a10d4d56a607c6ea1733a3 GIT binary patch literal 1179 zcmZ`&y>HV%6hD8&aohy86tz-?ip7G0Xkt61A5qn%U8qEmNLjpCj_+t)9NV0oRz(aE z>Wb8@D+3fz`3I27iWpO=LuHAHi7isLPQ1%Un?iYd_j`Bm-n;Yrz4OOhPC+2w-#mA3 z^9cQ7i-EwpE1fkk2M8k*VGi>iSLaQhV{^e1OcAW;rRtI?fiGpIv1H0vFf%X|tYCRh zG)KT@z-F;fMfs6eJ2u8@6jNf?BWo)Pd^HYK+lk$6H{Mm0r)1lWT<|_xEvpX=Tc5)V zhNc^fy&V#jRmUmS45KtZUvg^73LF#@brqnj8k#XTqv5XBpmL@&K0JA6-)C5yF%g<0L!jnZr)Q4%+(?{wMdgA7)H(RmGUfMTxi z2eWlC$6TBm^0WA^VE&3<9QY4max_>VEPN3UA?N5K?+{vYG-wL~x)IQssw7IEJC{UB zc9_?7s8aSDo@duQl21`~5=3KcIf2)x`w1V}iL}z2i)+;NVFXZWGEH6BUQqve%hL=h4Y3YrLErQn*=`F4|7+?_9b=RlAm zxu8ht0x1*}=^+1uj^-?-u(pasMQ15p8klvQhd}~Pv)|6_H#;*syZ1SnR1thXpOEG^ z5uxwgIQ!(0a{LvPJ+z82(h(N0XbX<0i;kpA0=Fewb|QLYsLOU#kKu@}kT_9sbP0Ci zm_9)y636nIc;hTK0#+HavYy~95lQ@l1z+jH0^j(^H~AmF>d-fZ6Ypg`g;O{Qa~jKa zluf_ed5m$!HvPcx$+|;am@U&af~G_Kz;rxgOHAyHOfV#`}$iOb2DLPvGL`lWO%iCb@6yp?)@ukso5kARIyEt&(-41#CyD`d(2=Zkmgywu|EiIuz-d_H zDeygX3V_9VJ5HnX$TvB0g#&lViZlV!N_LXP31+iSuO-UZ4ZIg5?XNF&@* z{Y~4ntqtOAGtoCGFO^AtKv~)_OxJA%X22U^7&Oj9@IQ!7aNwt-yoldwJW-aM7H-?* z5xoU0zXG4Xg@Ggp!a$4)@&KJf3JLQey8Wjdp=;r|^wIV3Oa=;?n(HXLuy+=uhi3b? zi=E_dyIcQwr~B$KJr|-PNDmeJrSgclyT9-$_vLChlRHf3LsSOop>jX3b=2Kw-TCg@ jed)u_J`Shv9VYIFNCW91Z4ecO_yCPVV%!{Ohj;M{J`5X- literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/8cc7963c8db0_add_last_segment_scan_timestamp.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6167835cf97ba2e0fd802f65f6cd665893c7d98e GIT binary patch literal 1127 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 Q0~c=_`|C;Gmb7;5AJ4nyxc~qF literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/cc3b223773cb_add_max_power_to_segmenteffort.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0748a2918ef5703aebcbf4c0ba385ef2a926687 GIT binary patch literal 1460 zcma)6&2Jk;6rcUDz25i(0&PJ^t(CMA1|_z4F%edUL_ZEi!H1fNW-p`Nok{9;zdE~a z8$P5QddM*cq;lYp1B9aT4D_+Z4};pZ zE1jC_nvP+ZYip+KOg%L2)QID>OsDDC%~o5#%&48v+7*CK&1f1Qt~6UKEd$I>%j_8D z>PKsB^Mkgo|0S+om$}xXcLp?YDbH;La8O<1au{`a>EvNtV_6-%bn-Y}CgHsR&z)t5 z*@1f#d$vQpnCpZ_G$1h!1DuW zi4ggP_1Wt{hv0pj#Xz@}U(`d$c|6ZMlwk&AD)34WbRyT~)p*K0rW5L8UYz&kb09?v z1|D3?qxBpU^>E!L1iPU(@B=Q#HqT$4+J$RDLboaV86Arwo~<|RC~7#idmE@|*dFzr zzT3DiSRt2cbQ;HS{3!{u9CW@UL%Fckri{j->uksGe#fSw=uhLE5IDc?^s2U^HNM}09 zhP_J=)~&zB3mB8o#hB-lov06+iRziS1}|hhXRV6wc(&)>r2Y=OuNF=1@V> za*CE-)~D!n`l248GwDpn8hZWQuC@ocXF-SP?9ozlx4bvl?|pZEe{)nlm!c-<5H*il z?acA+f%*N)qc_u2E2HXairSz<)IM4@cJ;lh`^)?H50r1eIUwocd!y3(DKbEZ$Qb8k T$&$wCB`nF3DVARelZN~QdqZWQ literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/dbb13b0ba015_add_last_segment_scan_timestamp.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01a5a33e29302988f8e6f98ae1a9091dc3e84349 GIT binary patch literal 1482 zcma)6&2Jk;6rcUDz25i(BKm=%woA$Z7ZQ8db!e@q1+@rqs4F$?fjzKV?@W>{`_YSp`UKgC${>^QMvvf+~ezK$JOiR+U%vHeI@Z;&lFcEdpX;%ZyN z-LBs3HoA60Z%sTTZfm;UT+xlz#}^FZs8=Yl6QW%OXlq8pSXpZ5OS+-y7u)Ao+Rf(j zg^SJBc|%qI<#(@%Oj#p$dL(cN%iRj#pqjwMFzT?<(ZiTRvpQJm=y9x!!*2p><}7!q z9XOkoXLpGgGZmAF1URoF{f~YUe=MfwKL2z6 z$-*C}o}5Z;l4|P%ZG8wRZw%#)bgJ|?WkaP7tw0{F0@TDw zljblDqpVAm7dt+bo&bv#`9qpqWdY`V#2hA9>GW6A_bv_O%R~8cI@NJD>|KJePVFsL zuq+%pmc{bPcI3ilyn4#7!3tTUGqU14p6xlC#NVd#Jf^^7q&T6hYFTy=go&MSIxLG8 zxu5@(=o|;$X3Fb&q5h}JsvqK>M=sGt@bbqSbHYcGAP6HdFUTV_g9;MPrfBhH{Rvl{r4xYyPnG z%RA|@rD1hBMJ><)Y8@;aJL>Ld{j>dV_oVN?+r#Pnxnb$U6d9lcWQ_8nuqlktOIQ+x MPsZ!7$1yGc0T6p@bpQYW literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc b/FitnessSync/backend/alembic/versions/__pycache__/e9b8841a1234_add_segment_effort_metrics.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bc0cd5c963d6144c128249b53673c47a85581ca GIT binary patch literal 1592 zcmcIk&1)1%6tC{C`A8?@XHf94JFt#02c4Oo(Zs>*LES}#4d|-Dj?i{@Rg#H4-EDPG z)GR^{Iqg5N*F9)fiDyBG2SM*O(1%d4Cr{o6a`WV?`C=v@sNkdO*RNhx_xt*F{g=z> z2;|khrN*>|&Y)6C2r>~-i0Ua#)l?O!Yo2Ceg3T03nQ5Y#8JJm;-bCgE z$pGp^`x6zj>n)oQE27Iy>ceovoM4XUbjC^^Y?C zq9W8FoEKB0UlvoYTXhVhIx|ys9k-un_mKsJLZH1BMJ62s4rFzx6D3X!tO%{j^Iak8 zm>m!7TcB(Y7Kmss@fN(ZJ$!EqO1(WOH&VXUYii8vFtQ))H9h9_yK^N7D3X~M2gmsl zi3xjc*@{9+$xUct)^B;9?RZp7O1obq1-%LSuR*kE=HB(mT^i)kiHD|AUaH}G0b zU#O8S(wBOA>US@&W0AS)$8?#p7idrR=F^pu9fl>xcJEW4lx&YSorYUlqfD|hDvc7z z`ZA%=wuN1wcLc2-||J08DvhZoFt!At=0VuBYrUK~BjOTWEm?;gEff49!> z-s1(kU9b~C>?GLXgTanKI4;bg$o9#a?QH^lnf65eTB;K5t54$nC%cqL&9e-|!X_oi zONz-~v#9o3J4sj&_It}@g}W)Sqo9ci5$ z=VbMy?cZh_*AiUgxHdH1Z(}3D1|N*Blj5YvSQZIf%MzJBOZt3^9(cCruF&RVmXncW zS^sVmIT;fWwRhEtoWpG2U9o`C)Qf~pXb7B(tibP5hRL}1rS&O$p&5{tM}K8M!>+uG zq7a~?DT>lj(+ch&2?vmJoTFo-V;6y?KRRhNbz&pCd+4W)-0q=ifZX8?y@NHyRXS({ Nbxo;C!+z-2;4_f@XpaB@ literal 0 HcmV?d00001 diff --git a/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py b/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py new file mode 100644 index 0000000..a88ed2a --- /dev/null +++ b/FitnessSync/backend/alembic/versions/b43006af329e_add_avg_temperature_to_activity.py @@ -0,0 +1,46 @@ +"""Add avg_temperature_to_activity + +Revision ID: b43006af329e +Revises: a9c00e495f5e +Create Date: 2026-01-11 16:32:06.147407 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b43006af329e' +down_revision: Union[str, None] = 'a9c00e495f5e' +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.add_column('activities', sa.Column('avg_temperature', sa.Float(), nullable=True)) + op.alter_column('bike_setups', 'purchase_date', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + op.alter_column('bike_setups', 'retirement_date', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('bike_setups', 'retirement_date', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + op.alter_column('bike_setups', 'purchase_date', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + op.drop_column('activities', 'avg_temperature') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py b/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py new file mode 100644 index 0000000..80c814f --- /dev/null +++ b/FitnessSync/backend/alembic/versions/cc3b223773cb_add_max_power_to_segmenteffort.py @@ -0,0 +1,30 @@ +"""Add max_power to SegmentEffort + +Revision ID: cc3b223773cb +Revises: dbb13b0ba015 +Create Date: 2026-01-12 20:13:23.975345 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cc3b223773cb' +down_revision: Union[str, None] = 'dbb13b0ba015' +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.add_column('segment_efforts', sa.Column('max_power', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('segment_efforts', 'max_power') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py b/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py new file mode 100644 index 0000000..55c7c8e --- /dev/null +++ b/FitnessSync/backend/alembic/versions/dbb13b0ba015_add_last_segment_scan_timestamp.py @@ -0,0 +1,30 @@ +"""Add last_segment_scan_timestamp + +Revision ID: dbb13b0ba015 +Revises: 1136125782ec +Create Date: 2026-01-12 19:46:33.893542 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'dbb13b0ba015' +down_revision: Union[str, None] = '1136125782ec' +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.add_column('activities', sa.Column('last_segment_scan_timestamp', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activities', 'last_segment_scan_timestamp') + # ### end Alembic commands ### diff --git a/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py b/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py new file mode 100644 index 0000000..d3e9fe9 --- /dev/null +++ b/FitnessSync/backend/alembic/versions/e9b8841a1234_add_segment_effort_metrics.py @@ -0,0 +1,25 @@ +"""add_segment_effort_metrics + +Revision ID: e9b8841a1234 +Revises: 1136125782ec +Create Date: 2026-01-13 14:45:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'e9b8841a1234' +down_revision = 'cc3b223773cb' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('segment_efforts', sa.Column('avg_speed', sa.Float(), nullable=True)) + op.add_column('segment_efforts', sa.Column('avg_cadence', sa.Integer(), nullable=True)) + op.add_column('segment_efforts', sa.Column('avg_respiration_rate', sa.Float(), nullable=True)) + +def downgrade(): + op.drop_column('segment_efforts', 'avg_respiration_rate') + op.drop_column('segment_efforts', 'avg_cadence') + op.drop_column('segment_efforts', 'avg_speed') diff --git a/FitnessSync/backend/backfill_estimated_power.py b/FitnessSync/backend/backfill_estimated_power.py new file mode 100644 index 0000000..f881de1 --- /dev/null +++ b/FitnessSync/backend/backfill_estimated_power.py @@ -0,0 +1,18 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity +from sqlalchemy import text + +def backfill(): + print("Backfilling is_estimated_power defaults...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + # Update all NULLs to False + result = session.execute( + text("UPDATE activities SET is_estimated_power = false WHERE is_estimated_power IS NULL") + ) + session.commit() + print(f"Updated {result.rowcount} rows.") + +if __name__ == "__main__": + backfill() diff --git a/FitnessSync/backend/check_db_status.py b/FitnessSync/backend/check_db_status.py new file mode 100644 index 0000000..c6b47ca --- /dev/null +++ b/FitnessSync/backend/check_db_status.py @@ -0,0 +1,22 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity + +def check(): + print("Checking ID 256...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + act = session.query(Activity).filter(Activity.id == 256).first() + if act: + print(f"ID: {act.id}") + print(f"Avg Power: {act.avg_power}") + print(f"Is Estimated: {act.is_estimated_power}") + + # Also check if it's NULL + if act.is_estimated_power is None: + print("Is Estimated is NULL/None") + else: + print("Activity 256 not found") + +if __name__ == "__main__": + check() diff --git a/FitnessSync/backend/debug_activity.py b/FitnessSync/backend/debug_activity.py new file mode 100644 index 0000000..b357797 --- /dev/null +++ b/FitnessSync/backend/debug_activity.py @@ -0,0 +1,77 @@ +import sys +import os +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker +import fitdecode +import io + +# Add curdir to path to allow imports from src +sys.path.append(os.getcwd()) + +# Import models - adjust import paths if needed strictly relative or absolute +from src.models.activity import Activity +from src.services.parsers import _extract_data_from_fit + +# Using localhost:5433 as per docker-compose port mapping for host access +DATABASE_URL = "postgresql://postgres:password@localhost:5433/fitbit_garmin_sync" + +def debug_activity(garmin_id): + print(f"Connecting to DB: {DATABASE_URL}") + try: + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + except Exception as e: + print(f"Failed to connect to DB: {e}") + return + + try: + activity = session.query(Activity).filter(Activity.garmin_activity_id == garmin_id).first() + if not activity: + print(f"Activity {garmin_id} not found in DB.") + return + + print(f"Activity Found: ID={activity.id}, GarminID={activity.garmin_activity_id}") + print(f" Name: {activity.activity_name}") + print(f" Type: {activity.activity_type}") + print(f" Distance: {activity.distance}") + print(f" Avg Power: {activity.avg_power}") + print(f" Avg Speed: {activity.avg_speed}") + print(f" File Type: {activity.file_type}") + print(f" File Content Size: {len(activity.file_content) if activity.file_content else 0} bytes") + + if activity.file_content and activity.file_type == 'fit': + print("\n--- Parsing FIT File Content ---") + try: + data = _extract_data_from_fit(activity.file_content) + print(f" Data Keys: {data.keys()}") + print(f" Points Count: {len(data.get('points', []))}") + + power_data = data.get('power', []) + print(f" Power Count: {len(power_data)}") + valid_power = [p for p in power_data if p is not None] + if valid_power: + print(f" Avg Power (Calculated from stream): {sum(valid_power)/len(valid_power)}") + print(f" Max Power (Calculated from stream): {max(valid_power)}") + else: + print(f" Avg Power (Calculated from stream): None") + + none_power = sum(1 for x in power_data if x is None) + print(f" Power None values: {none_power} / {len(power_data)}") + + # Check lat/long + points = data.get('points', []) + if points: + print(f" First point: {points[0]}") + print(f" Last point: {points[-1]}") + + except Exception as e: + print(f" Error parsing FIT file: {e}") + else: + print(f" No FIT file content or file type is not 'fit' (Type: {activity.file_type})") + + finally: + session.close() + +if __name__ == "__main__": + debug_activity("21517046364") diff --git a/FitnessSync/backend/debug_estimated_power.py b/FitnessSync/backend/debug_estimated_power.py new file mode 100644 index 0000000..870644f --- /dev/null +++ b/FitnessSync/backend/debug_estimated_power.py @@ -0,0 +1,50 @@ +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.models.activity import Activity +from src.services.power_estimator import PowerEstimatorService +from sqlalchemy import text, or_ + +def debug_power(): + print("Debugging Estimated Power...") + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + # 1. Total Cycling + cycling_count = session.query(Activity).filter(Activity.activity_type.ilike('%cycling%')).count() + print(f"Total Cycling Activities: {cycling_count}") + + # 2. Missing Power + missing_power = session.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + (Activity.avg_power == None) | (Activity.avg_power == 0) + ).count() + print(f"Cycling with Missing Power: {missing_power}") + + # 3. Already Estimated + estimated_count = session.query(Activity).filter(Activity.is_estimated_power == True).count() + print(f"Activities with is_estimated_power=True: {estimated_count}") + + # 4. Candidates for Job + candidates = session.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + (Activity.avg_power == None) | (Activity.avg_power == 0), + Activity.file_content != None + ).all() + print(f"Job Candidates (Cycling + No Power + Has File): {len(candidates)}") + + if len(candidates) > 0: + print(f"First candidate ID: {candidates[0].id}") + # Try to estimate one + print("Attempting to estimate power for first candidate...") + try: + estimator = PowerEstimatorService(session) + res = estimator.estimate_power_for_activity(candidates[0].id) + print(f"Test Estimation Result: {res}") + except Exception as e: + print(f"Test Estimation FAILED: {e}") + import traceback + traceback.print_exc() + else: + print("No candidates found for estimation.") + +if __name__ == "__main__": + debug_power() diff --git a/FitnessSync/backend/inspect_fit_fields.py b/FitnessSync/backend/inspect_fit_fields.py new file mode 100644 index 0000000..8b7242d --- /dev/null +++ b/FitnessSync/backend/inspect_fit_fields.py @@ -0,0 +1,72 @@ +import sys +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import fitdecode +import io + +# Add curdir to path +sys.path.append(os.getcwd()) + +from src.models.activity import Activity + +# Using localhost:5433 as per docker-compose port mapping +DATABASE_URL = "postgresql://postgres:password@localhost:5433/fitbit_garmin_sync" + +def inspect_fit_fields(garmin_id): + print(f"Connecting to DB: {DATABASE_URL}") + try: + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + except Exception as e: + print(f"Failed to connect to DB: {e}") + return + + try: + activity = session.query(Activity).filter(Activity.garmin_activity_id == garmin_id).first() + if not activity: + print(f"Activity {garmin_id} not found in DB.") + return + + if not activity.file_content or activity.file_type != 'fit': + print("No FIT content found.") + return + + print(f"Parsing FIT file for Activity {garmin_id}...") + + record_fields = set() + lap_fields = set() + session_fields = set() + + with io.BytesIO(activity.file_content) as f: + with fitdecode.FitReader(f) as fit: + for frame in fit: + if frame.frame_type == fitdecode.FIT_FRAME_DATA: + if frame.name == 'record': + for field in frame.fields: + record_fields.add(field.name) + elif frame.name == 'lap': + for field in frame.fields: + lap_fields.add(field.name) + elif frame.name == 'session': + for field in frame.fields: + session_fields.add(field.name) + + print("\n--- Available 'record' Fields (Time Series) ---") + for field in sorted(record_fields): + print(f" - {field}") + + print("\n--- Available 'lap' Fields ---") + for field in sorted(lap_fields): + print(f" - {field}") + + print("\n--- Available 'session' Fields (Summary) ---") + for field in sorted(session_fields): + print(f" - {field}") + + finally: + session.close() + +if __name__ == "__main__": + inspect_fit_fields("21517046364") diff --git a/FitnessSync/backend/main.py b/FitnessSync/backend/main.py index 31fed25..2312e19 100644 --- a/FitnessSync/backend/main.py +++ b/FitnessSync/backend/main.py @@ -98,6 +98,9 @@ app.include_router(bike_setups.router) from src.api import discovery app.include_router(discovery.router, prefix="/api/discovery") +from src.api import analysis +app.include_router(analysis.router, prefix="/api") + @@ -107,3 +110,5 @@ app.include_router(web.router) + +# Trigger reload diff --git a/FitnessSync/backend/requirements.txt b/FitnessSync/backend/requirements.txt index d98fd38..e4d45b5 100644 --- a/FitnessSync/backend/requirements.txt +++ b/FitnessSync/backend/requirements.txt @@ -15,3 +15,4 @@ aiofiles==23.2.1 pytest==7.4.3 pytest-asyncio==0.21.1 alembic==1.13.1 +fitdecode>=0.10.0 diff --git a/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/activities.cpython-311.pyc index d422953ee984664944836d67cc4688a0160a7c11..ca80274fb94b89ae2a8826f12217147c26628501 100644 GIT binary patch delta 10390 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 H9rXVLnsddS delta 8414 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 delta 3685 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 delta 994 zcmZXS&rcIU6vuaVw*@IbS{7)bU0R9=tC$9ZXn^p;5cy3IFp7rq!x?_mLYvvvL@s*3 z#DgS|xf;R=6XjwyG4ZAc58fcbX!bwg23$yp2j5#jqfYWZ^JeC~@0(w{T_+udZwd4gs^>xBXNNPb@zCPI*Fy2f&5Wm>Z0+JcC880NGGVdmeAh}|O1CDBqA5*wtp zvy4?yKhr7ZxM|?OoTw~yOSF7Nq7}@wrGOsTnO-&MT|CqCo$6_RR_$o1GO zDGhDwVw2QVVMVN_(#rFQ--@;8iq|C?rjc!ZONNSIr;S1`oUCd^{pVzL)IBO0Q7fVB zxU0P}^U*w`+2q2~Tr82AF%4oDa%QGeOD3CKOi`8;pFPKWpqR(B8uom^dZ6s{4Bf& zU@Oy<@_rl+h&6xAeRhN|{$|$%%s#Z#z$-#Jt@ZLdq^}L*+7TUqAtRSJ;lL?)8@(W+ z8{zEA4R^~j%+WU#?%Yu$t`3U&V6AH!+-2*2aFrDQt!$`#L>z~7(jrRrI^R6Ry#O=E z0neoax9|o^#Ejle4DnVUC%r6Dq_coBRGfjj279c~~lBm97q+}2|FN!cW8<4<6W zKgSGOKPxARSdAoZMoH0u1lZJ^ZYS36^q=lI-5(q(k~~EA$l!sDDLq!KDoTC<-U{e( diff --git a/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc b/FitnessSync/backend/src/api/__pycache__/segments.cpython-311.pyc index ae8e55bd2c284cbfe8b846526638bc775c4a06a0..f5a0c247c6ca8b8cb1b6aa15240609cf467b677f 100644 GIT binary patch delta 7247 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 delta 5694 zcmcIoYiwM_6~1$K_wMtqAN%mGpKHgj&BHNGNKEX6I0c8`;P430EFos&d+n^*SLUu0 z9M)_=5doS&&;${rR5eu_ND~Uws#GDM`B7DEic}|ZmDLqUluDKQqm@ypg!s|+oVnhI zy%hMvbx7`ls^@|UB;aS1NF8r;Lt9lvTY$G!pAZ5{O+{l+_086^mutPOhxj)5FAx|OenuH5 zibt(eysCZNtoTk?R;bkgeH*c_r1(`y4YD(&Wqsuk*j-ct72TUk-Gk6QfZcON?OPVNk znGJ=SAWYUX*%Q2N8K=;ibu2f=%Yh4^`z6$$mzmluVbvzTKU=$8kO)}jbS=05O7NYh0t#nY$U z?)#9s4PgsHKl{Z0=m9+XaGPG0NGWPu>nR*Q9R$8!t*P`#Y)sX9(oqVuM!%12`&1?d zvJ+~G-hm_CiA>AkDV0v^(s(SMQ7Ppn)xgfx^{(L>G@@V=3Yy|-l8*2$G@6`HsS}&H zhFjQIb%SIt8wj+l@zbry--dw7@Ttq@vlUhCXU_(@r2A23gv|vy8?2f-kx-Kvx)*r5 zH4)8>9a5=IRL#%a!C)(Qc-=il)o4Z?EeIb0VYC+Qm!`Sl;Ok=Rza*i`pO?L-_UF9~ z3*N4LjW1sp%KMua{A=g^x4mO$2O7Whi(PlG7Ot*#3|E`3Zmb=4n*Qjt3|G&^n_d%h zhF_s#EB79G=qX{p?$8d$qjAtMF-@nDA3?xP(Jf=CDOiRJ890`1Pffw*PNrf>ims&F zCTMCZJ$i6jH$~%d-4WAbNi7pij;XX?5C)8(K@ak=AaE#}E=AL6HK}lKG;CwJYGJFf z<}g78G!1#fe%B5V-%oqt-Jt@ft{C0cYy+~t&FZJ(Cm)(K_ami+6Nz& zK*m$x1MMxNF);F?pj5F&o#i#f`Z(7+Nfeu6yKKJzdR&s(Uv~H%L^$R^A^AuK}M(9G|vuH?hhbzva+hba4JVn6`N)z&XgZ6?n95^3s8($2c zdy?JgT-%AeqkAXR%xJ;WM%8f`C!^8R&~z4lTAHwM*U$T^EY3v%;5jL8tGQtdT(<@O z9PG(g`A$ve>zWqoy7R%7^UVvvwfR8vLSQx2knY>Yc6B$J@*B6Xba(x}yeD{eYu?|u z;P1@)8qSX9y@C9?4VOpfJv+|~o!v8g_`0`y-rIepBVSv$*x;^rd@cZdfdG`<36vew zC_=N~^39vQw1eFnZY1HkMA#T z1W<^0?BBOEIbQ+BZ?PpuSYYjg?8)y<-1;3%KXktF8|*&!f3thg^q717()FgT*T&#p zAiyjX3@0W5w&v!0K*1Z6z*`dDm@@>Pe?&3G;hi}n@+YDtZk{ng>SICs^DEMdl*XJj z%}Oll@pX|hOEhQ2aS<#6g6XB=MF3`OnYCdCg1e8R&x+iab!jqna5Scb?Yg&^ z$sj8cO-3Q{ps&I#dJs5T&$5|vvE;-ku<1DzuxAcIrl0^%cP!-$(56d!Ia_zrsU)~D zjYZ?JW2&NCxDsK>xJPjcJ`DXaQs;3%$rx~%fgxYUHDE-j#_UC-KLh5c@Y9}U(ajCH zJLb*H{_)VcFE?G2V+(TZ_)y;LIJx_o-A|94fR|3bZr!i_^Um$~XWq_V62}@#_A_VgYc#i) zuDl08Gr9v|`Q=B4+0KC>@(_D*;QkhTlY|L(G&nl3ru2IgDy7&cfCqB%q-0uRu9QCqNj5Mc~KLEtAcj#M{5 zST1`8te7EzLP>-aLK@*P0!85O0e163?ovA614$RfTUU24J+GFc z40W>BT@A#=w(e@pp?bQ@cv6j}lH;)nis`OyOHV7&WF|I7tDtHm@e-u__`32VCxFm} zuo*x%r&C&n;wwfsL44#Hx$*CV(PYJM71Ul_o6j5}OAlgngJkx;DmNYMr=6tAY q*7~OOdeu*>76m66BnF Dict[str, List[Any]]: "power": [], "altitude": [], "speed": [], - "cadence": [] + "cadence": [], + "respiration_rate": [] } try: + import fitdecode + import io + start_time = None with io.BytesIO(file_content) as f: with fitdecode.FitReader(f) as fit: @@ -626,8 +679,68 @@ def _extract_streams_from_fit(file_content: bytes) -> Dict[str, List[Any]]: streams["altitude"].append(get_val(frame, ['enhanced_altitude', 'altitude'])) streams["speed"].append(get_val(frame, ['enhanced_speed', 'speed'])) # m/s (enhanced is also m/s) streams["cadence"].append(get_val(frame, ['cadence'])) + streams["respiration_rate"].append(get_val(frame, ['respiration_rate', 'enhanced_respiration_rate'])) except Exception as e: logger.error(f"Error extracting streams from FIT: {e}") + + # Apply LTTB Downsampling + try: + from ..utils.algorithms import lttb + target_points = 1500 # Plenty for 4k screens, but much smaller than raw 1s data + + # We need a primary axis to sample against, typically Time. + # But LTTB is 2D (x,y). We have multiple Ys for one X (time). + # Strategy: Use Time vs Power (or HR/Speed) to pick key indices? + # Or simpler: Just LTTB each stream independently against Time? + # Independent LTTB might misalign peaks across streams (e.g. HR peak might slightly shift vs Power peak). + # Better: Pick 'Power' (most volatile) as the driver for indices? + # Or Simple Decimation for speed? + # Actually, let's just LTTB each one. The slight misalignment is negligible for visualization. + + # Check if we have enough points to warrant sampling + count = len(streams["time"]) + if count > target_points: + # Create (time, index) pairs to find which indices to keep? + # No, standard LTTB takes (x,y). + + # Helper to LTTB a specific stream + def sample_stream(name): + if not streams.get(name) or len(streams[name]) != count: return + + # Filter out Nones for LTTB? No, preserve index? + # LTTB requires values. If we have gaps, it's tricky. + # Let's replace None with 0 (or prev value) for sampling purposes? + # Or just use simple uniform sampling (decimation) which is "good enough" and keeps perfect alignment. + pass + + # CHANGING STRATEGY: + # LTTB is great for one line. For aligned multi-series, simple bucket averaging or decimation is safer to keep alignment. + # However, decimation loses peaks. + # + # Let's try: "Bucket Max/Avg". + # Or simplified: Use LTTB on the "most interesting" metric (Power) to select the timestamps, then sample others at those timestamps. + + # Implementation: Use simple N-th sampling for now to guarantee alignment and speed improvement. + # It's an order of magnitude faster than full LTTB and robust for "Loading Speed" requests. + + step = count / target_points + indices = [int(i * step) for i in range(target_points)] + # Ensure last point included + 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 idx < len(streams[k]): + sampled_streams[k].append(streams[k][idx]) + + return sampled_streams + + except Exception as e: + logger.error(f"Error during downsampling: {e}") + # Return original if sampling fails + return streams def _extract_summary_from_fit(file_content: bytes) -> Dict[str, Any]: @@ -745,18 +858,71 @@ async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)): if not activity or not activity.file_content: raise HTTPException(status_code=404, detail="Activity or file content not found") + # 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': - streams = _extract_streams_from_tcx(activity.file_content) + # 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', []) + } 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 except Exception as e: - logger.error(f"Error getting streams: {e}") - raise HTTPException(status_code=500, detail=str(e)) + logger.error(f"Error processing streams: {e}") + raise HTTPException(status_code=500, detail="Error processing activity streams") @router.post("/activities/{activity_id}/estimate_power") async def estimate_activity_power(activity_id: int, db: Session = Depends(get_db)): diff --git a/FitnessSync/backend/src/api/analysis.py b/FitnessSync/backend/src/api/analysis.py new file mode 100644 index 0000000..45cdc24 --- /dev/null +++ b/FitnessSync/backend/src/api/analysis.py @@ -0,0 +1,232 @@ + +from fastapi import APIRouter, Depends, HTTPException, Body +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import date, datetime +from pydantic import BaseModel + +import json +from io import StringIO +from fastapi.responses import StreamingResponse + +from ..models.segment_effort import SegmentEffort +from ..models.activity import Activity +from ..models.bike_setup import BikeSetup +from ..models.health_metric import HealthMetric +from ..services.postgresql_manager import PostgreSQLManager +from ..services.parsers import extract_activity_data +from ..utils.config import config + +router = APIRouter() + +def get_db(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as session: + yield session + +class EffortAnalysisData(BaseModel): + effort_id: int + activity_id: int + activity_name: str + date: str + elapsed_time: float # seconds + avg_power: Optional[int] + max_power: Optional[int] + avg_hr: Optional[int] + avg_cadence: Optional[int] + avg_speed: Optional[float] + avg_temperature: Optional[float] + + bike_name: Optional[str] + bike_weight: Optional[float] + body_weight: Optional[float] + total_weight: Optional[float] + watts_per_kg: Optional[float] + +class ComparisonResponse(BaseModel): + efforts: List[EffortAnalysisData] + winners: Dict[str, 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)): + """ + Compare multiple segment efforts with enriched data. + """ + if not effort_ids: + raise HTTPException(status_code=400, detail="No effort IDs provided") + + efforts = db.query(SegmentEffort).filter(SegmentEffort.id.in_(effort_ids)).all() + if not efforts: + raise HTTPException(status_code=404, detail="No efforts found") + + results = [] + + for effort in efforts: + activity = effort.activity + + # 1. Bike Data + bike_weight = 0.0 + bike_name = "Unknown" + if activity.bike_setup: + bike_weight = activity.bike_setup.weight_kg or 0.0 + bike_name = activity.bike_setup.name or activity.bike_setup.frame + + # 2. Body Weight (approximate from HealthMetrics closest to date) + # Find weight metric on or before activity date + act_date = activity.start_time.date() + weight_metric = db.query(HealthMetric).filter( + HealthMetric.metric_type == 'weight', + HealthMetric.date <= act_date + ).order_by(HealthMetric.date.desc()).first() + + body_weight = 0.0 + if weight_metric: + val = weight_metric.metric_value + unit = (weight_metric.unit or '').lower() + + # Heuristic: Value > 150 is likely lbs (unless user is very heavy, but 150kg is ~330lbs) + # Fitbit data showed ~200 marked as 'kg', which is definitely lbs. + if unit in ['lbs', 'lb', 'pounds'] or val > 150: + body_weight = val * 0.453592 + else: + body_weight = val + + total_weight = (body_weight or 0.0) + bike_weight + + # Watts/kg + w_kg = 0.0 + if effort.avg_power and body_weight > 0: + w_kg = effort.avg_power / body_weight + + data = EffortAnalysisData( + effort_id=effort.id, + activity_id=activity.id, + activity_name=activity.activity_name or f"Activity {activity.id}", + date=activity.start_time.isoformat(), + elapsed_time=effort.elapsed_time, + avg_power=effort.avg_power, + max_power=effort.max_power, + avg_hr=effort.avg_hr, + avg_cadence=activity.avg_cadence, # Use activity avg as proxy if effort specific not available in DB + avg_speed=activity.avg_speed, # Proxy + avg_temperature=activity.avg_temperature, + bike_name=bike_name, + bike_weight=bike_weight if bike_weight > 0 else None, + body_weight=body_weight if body_weight > 0 else None, + total_weight=total_weight if total_weight > 0 else None, + watts_per_kg=round(w_kg, 2) if w_kg > 0 else None + ) + results.append(data) + + # Calculate Winners + winners = {} + if results: + # Helper to find min/max + def find_winner(key, mode='max'): + valid = [r for r in results if getattr(r, key) is not None] + if not valid: return None + if mode == 'max': + return max(valid, key=lambda x: getattr(x, key)).effort_id + else: + return min(valid, key=lambda x: getattr(x, key)).effort_id + + winners['elapsed_time'] = find_winner('elapsed_time', 'min') + winners['avg_power'] = find_winner('avg_power', 'max') + winners['max_power'] = find_winner('max_power', 'max') + winners['avg_hr'] = find_winner('avg_hr', 'min') # Lower is usually better for same output, but depends on context. Assume efficiency. + winners['watts_per_kg'] = find_winner('watts_per_kg', 'max') + winners['avg_speed'] = find_winner('avg_speed', 'max') + + return ComparisonResponse(efforts=results, winners=winners) + +@router.post("/segments/efforts/export") +def export_analysis(effort_ids: List[int] = Body(...), db: Session = Depends(get_db)): + """ + Export structured JSON for LLM analysis. + """ + # Reuse comparison logic to get data + # In a real app, refactor to shared service function + comparison = compare_efforts(effort_ids, db) + + # Convert to dict + # Convert to dict + data = [] + for e_obj in comparison.efforts: + e_dict = e_obj.dict() + + # Fetch and slice streams + # 1. Get Activity + # We need the activity object. comparison.efforts only has IDs. + # Efficient way: query them or cleaner: just query needed activities using the IDs. + # 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: + try: + act = effort.activity + raw_data = extract_activity_data(act.file_content, act.file_type or 'fit') + + # 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 + + # Helper to align + 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 + 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 + + # 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] + + 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}") + e_dict['streams'] = {"error": str(e)} + + data.append(e_dict) + + # Create JSON file + file_content = json.dumps(data, indent=2) + + return StreamingResponse( + io.BytesIO(file_content.encode()), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=efforts_analysis.json"} + ) +import io diff --git a/FitnessSync/backend/src/api/scheduling.py b/FitnessSync/backend/src/api/scheduling.py index ae17458..5096421 100644 --- a/FitnessSync/backend/src/api/scheduling.py +++ b/FitnessSync/backend/src/api/scheduling.py @@ -38,6 +38,12 @@ class JobUpdateRequest(BaseModel): enabled: Optional[bool] = None params: Optional[dict] = None +@router.get("/scheduling/available-types", response_model=List[str]) +def list_available_job_types(): + """List available background job types.""" + from ..services.scheduler import scheduler + return list(scheduler.TASK_MAP.keys()) + @router.get("/scheduling/jobs", response_model=List[ScheduledJobResponse]) def list_scheduled_jobs(db: Session = Depends(get_db)): """List all scheduled jobs.""" @@ -52,13 +58,14 @@ def update_scheduled_job(job_id: int, request: JobUpdateRequest, db: Session = D raise HTTPException(status_code=404, detail="Job not found") if request.interval_minutes is not None: - if request.interval_minutes < 1: - raise HTTPException(status_code=400, detail="Interval must be at least 1 minute") + if request.interval_minutes < 0: + raise HTTPException(status_code=400, detail="Interval must be at least 0 minutes") job.interval_minutes = request.interval_minutes # If enabled, update next_run based on new interval if it's far in future? # Actually, standard behavior: next_run should be recalculated from last_run + new interval # OR just leave it. If we shorten it, we might want it to run sooner. + # OR just leave it. If we shorten it, we might want it to run sooner. # Let's recalculate next_run if it exists. if job.last_run: job.next_run = job.last_run + timedelta(minutes=job.interval_minutes) diff --git a/FitnessSync/backend/src/api/segments.py b/FitnessSync/backend/src/api/segments.py index 5615b6b..0d93312 100644 --- a/FitnessSync/backend/src/api/segments.py +++ b/FitnessSync/backend/src/api/segments.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from typing import List, Optional from sqlalchemy.orm import Session from ..models.segment import Segment @@ -21,6 +21,7 @@ class SegmentCreate(BaseModel): activity_id: int start_index: int end_index: int + activity_type: Optional[str] = None class SegmentEffortResponse(BaseModel): id: int @@ -32,6 +33,10 @@ class SegmentEffortResponse(BaseModel): end_time: Optional[str] avg_hr: Optional[int] = None avg_power: Optional[int] = None + max_power: Optional[int] = None + avg_speed: Optional[float] = None + avg_cadence: Optional[int] = None + avg_respiration_rate: Optional[float] = None kom_rank: Optional[int] pr_rank: Optional[int] is_kom: bool @@ -94,13 +99,26 @@ def create_segment(payload: SegmentCreate, db: Session = Depends(get_db)): if diff > 0: elev_gain += diff + # Determine Activity Type + raw_type = payload.activity_type or activity.activity_type + final_type = 'cycling' # Default + + if raw_type: + rt = raw_type.lower() + if rt in ['running', 'trail_running', 'treadmill_running', 'hiking', 'walking', 'casual_walking']: + final_type = 'running' + elif rt in ['cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling']: + final_type = 'cycling' + else: + final_type = rt + # Create Segment segment = Segment( name=payload.name, description=payload.description, distance=dist, elevation_gain=elev_gain, - activity_type=activity.activity_type or 'cycling', + activity_type=final_type, points=json.dumps(simplified_points), bounds=json.dumps(bounds) ) @@ -171,6 +189,10 @@ def get_activity_efforts(activity_id: int, db: Session = Depends(get_db)): end_time=effort.end_time.isoformat() if effort.end_time else None, avg_hr=effort.avg_hr, avg_power=effort.avg_power, + max_power=effort.max_power, + avg_speed=effort.avg_speed, + avg_cadence=effort.avg_cadence, + avg_respiration_rate=effort.avg_respiration_rate, kom_rank=effort.kom_rank, pr_rank=None, # Placeholder is_kom=(effort.kom_rank == 1) if effort.kom_rank else False, @@ -214,6 +236,10 @@ def get_segment_leaderboard(segment_id: int, db: Session = Depends(get_db)): end_time=effort.end_time.isoformat() if effort.end_time else None, avg_hr=effort.avg_hr, avg_power=effort.avg_power, + max_power=effort.max_power, + avg_speed=effort.avg_speed, + avg_cadence=effort.avg_cadence, + avg_respiration_rate=effort.avg_respiration_rate, kom_rank=effort.kom_rank, pr_rank=None, is_kom=(effort.kom_rank == 1) if effort.kom_rank else False, @@ -222,13 +248,27 @@ def get_segment_leaderboard(segment_id: int, db: Session = Depends(get_db)): return responses @router.post("/segments/scan") -def scan_segments(db: Session = Depends(get_db)): +def scan_segments(background_tasks: BackgroundTasks, force: bool = Query(True, description="Force full re-scan"), db: Session = Depends(get_db)): """Trigger a background job to scan all activities for segment matches.""" from ..services.job_manager import job_manager - from ..jobs.segment_matching_job import run_segment_matching_job - import threading + from ..tasks.definitions import run_segment_matching_task + from ..services.scheduler import SchedulerService - job_id = job_manager.create_job("segment_match_all") + # Check if job already running? + # For now, allow multiple, manager handles it (usually) + + job_id = job_manager.create_job("segment_matching") + + # We need to pass the session factory to the background task + # because we can't share the 'db' session from Depends across threads safely/easily + # without deeper integration. + # The SchedulerService uses db_manager.get_db_session. + # Here we will re-instantiate a manager or use a global one. + db_manager = PostgreSQLManager(config.DATABASE_URL) + + background_tasks.add_task(run_segment_matching_task, job_id, db_manager.get_db_session, force=force) + + return {"job_id": job_id, "status": "started"} # Run in background thread = threading.Thread(target=job_manager.run_serialized, args=(job_id, run_segment_matching_job)) 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 4356c41d9058826a50c20265b0f3344c3cba477d..625e5bd65cf732e72bbb3059cf8a686bd0d0a488 100644 GIT binary patch literal 6760 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 UR@`Y0;Z5nDJOcJcB1XXdA2G<*bpQYW diff --git a/FitnessSync/backend/src/jobs/segment_matching_job.py b/FitnessSync/backend/src/jobs/segment_matching_job.py index a5abdfb..383a810 100644 --- a/FitnessSync/backend/src/jobs/segment_matching_job.py +++ b/FitnessSync/backend/src/jobs/segment_matching_job.py @@ -12,20 +12,31 @@ from ..services.parsers import extract_points_from_file logger = logging.getLogger(__name__) -def run_segment_matching_job(job_id: str): +def run_segment_matching_job(job_id: str, db_session_factory=None, force: bool = False, **kwargs): """ Job to scan all activities and match them against all segments. """ # 1. Setup DB - db_manager = PostgreSQLManager(config.DATABASE_URL) + if db_session_factory: + get_session = db_session_factory + # If it's a context manager factory vs a session factory, we need to handle that. + # The scheduler passes `self.db_manager.get_db_session` which IS a context manager factory (returns contextlib.contextmanager) + # So we can use `with get_session() as db:` + else: + db_manager = PostgreSQLManager(config.DATABASE_URL) + get_session = db_manager.get_db_session - with db_manager.get_db_session() as db: + with get_session() as db: try: # 2. Get all activities and segments activities = db.query(Activity).all() total_activities = len(activities) # 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 = [] @@ -49,6 +60,7 @@ def run_segment_matching_job(job_id: str): 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. @@ -63,7 +75,32 @@ def run_segment_matching_job(job_id: str): # 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: {skipped_far})") + 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})") + + # 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: @@ -95,11 +132,16 @@ def run_segment_matching_job(job_id: str): try: points = extract_points_from_file(activity.file_content, activity.file_type) if points: - # Clear existing efforts - db.query(SegmentEffort).filter(SegmentEffort.activity_id == activity.id).delete() + # 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) + 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") diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-311.pyc index 6f4394b386d71dcb5ae78e8039d82f69eb7aeb53..a15157a55d269cda1dbfcb5f5c51aaf82b984927 100644 GIT binary patch delta 1719 zcmb7DO>g5w7`79qPV@bprfGLKY0_*rv_P6omhHzjOScP4*&Yfk`jGeX#+Hzo&89(pyJoEaU zcbxBIpN@IHbh{lG8to4&?1^X3GlXyLWwH40I70IM;$uv|NFoSwm#FkNe@= z89ilxInUy;-ZcFqJwG|x!?4lS;m_8ywkQx zn^zdUjquBidml#~LG!dDi*UvE+E5fl6Sz(?ph9nQvQE}Om0_#v@LStm{0=;^!BIWV z(UMF_V4Vk5^jxE>l+5x#l4-sMKOQ*R(Y^H6Iwb?X21Huk6oKxL1)1ikZ9#mb``D@s zsuC+dp(^y6EQq>S-VlMbA#e;;6SjdUI#CsRfR!kel;vp|oC^A|kyvEcH)N^?7?e@s z1wr1Z0!h+G4UGd8d51ztA8!H*R4QOiX19Q;U+V3>P$$+!R@R9MCv58>(+i{;iw?jF zRSG?T9^&cuC{*dcS%=}3WW-Cl-M}+aO~~o)?varoJOYv)JawUfVp!Cx>KYJT#yMTa zUYeJvhmuf*Jb4TEz;DQ9+zn62SziEIL}M91Q)&`$$^J#Slf2p*Zx0ypb&{pI@@}yeFs&WE~)8~mPXYkQBAJE zZlRHGr4(mIb!IeYroPy5`k&#Db0pa;w9*GDC0kn&YkTK}Z=csKW(qxS$ZxqhPF2 zXr|gBB{-!9r?lWyeYpeQcl_ac=j<--PXwUs%#qFNRQ~)A-7NSY-4glb delta 841 zcmZ`#L2J}N6rPvKPSPYhn?hGMXwkB)YudWDpj5VH_262h#R|Oz5lbg(CE04y9=zy9 zP>;*AM=yd~N{bi0`6Kiwd(d7KM34%e#DkOU_FzpP^B(Vg-+SN8yp8fZhi~Td29eg> zT3(5bHZMV|T^(BhB8X6efza@bG^c?XTAn3yRpf$v$UP23A+toV$3$p8?@&UvrT5G$ z2z`;b1K*O;)!SQF5MWvvRj~o@7r0P%SVxfq(tG9=6ymFX7GodE4E7kjt~l7FL-SZUpMp{oi1*3x>ghA*^Pn8Ys{;)cG9uZ;^};SXaT zOq@1nY+H^+Nnq0B;}oBmFW)q*=K#AnRrqc-_xY=?jWzr1qb`r{2TQ^79Y1_fTaCiV zlpX(ol8>weWZ|mtgE->*8quXBOC>MM?6*E6JJtig5zWam`BA(r!yW~IkL1ROj8n-( b4}y4X@ch&`jO~&C!_&2&3HmF8N}u)@)~C6C diff --git a/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/activity.cpython-313.pyc index 9db16057a3c190e703e78967a5ae4cdd8e5b1b02..a2963f8b4a055ff12b335f12b836c735c03c6f0f 100644 GIT binary patch delta 1551 zcmZvbPi)&%9LMe0`TxgFo4@VSHX$L+!Zzril`^($qm@_+E5l4k5MfO6l9<_X`uw_f zr{p+sVa1nn%Yj40smF>dCvNd!nqsK}!HFA)?as5~XnWEk#qaldzwhts-_L%2Joj@t z@?SU{pz!a9Js=Gu$B`8M?QyZZTlGW#GR0H8Yd260!k`^9yP;|rhH=dD?%hb0gWNKe zqH+}PnWuPfjoV->D~f_MEnmt1w-uKB$3Ncjo8H^1Dg&`|{O2{-SB$zY zyg^W&paMZ}5>&Kl>bYW5iFflp{8%m$%JPJIPSbA@$x8%XCg=)5Zxi$mL8}C<*)(l! z-O1gXys`}&uR6_a+T?TSU5EVk6k9e;U-f$v!Uu-o16y?M+NVeQ6(*Am;(4#XHkYm3 zu=hQ+3a$!o(AJrvta|Sj18>^7>Cw4WyNxxGsRn+)b8uV#p1GS!;*t_=dK;iA9>~b_ zc0s2hbz1rh<_^81SJq>Y4N$m)D0cK1Qj|AK)xf`&w8p8XU>9gV$t*fq@;mOE2p*{ebIp?S_ z2n`88t4NkJ>P`Kxs_q+Y}?4) z8nH`y#rwQBPFy&-Km5!{toHZ*^-!~^6ZeocqL=#BaWZoPh7BXRHezFz?DHWq66^iF zae8UsJ&hab%@LazXNyDk8Ea&(kJ!a={^D@sY~9G;9I@GPp*-Bd_bT9f<*;>V_DNxo zH-g3f_Ba@I;(PsVoVt)36i)NT0ya#I7qYKFH2Jb0F8%}z8U|PHe~4@13n!bn@7QYp z1pi&@}x!)p|y?o>HxIs(4N< Vom1(#=j;wmmmi}Sl(pJE{{!1|rNRIJ delta 739 zcmZvYy>AmS7{=|p%bo8dPD-IoKnEoS(j%dKRw96?7!XZ@GGN$Fk<#Y+Azj+yYX=sx zRvC~Y&ulF42k?(DAVrE`U}2&XWn#g0?*>bpboTpu-{*N``&0R77XQxY9l*b*A2)Uq zXN*g5b6l-GTL1(Qt!=j)>d0DeyDg7;yf#R#oo``^7eEPA05Q)4Vug4`7cl_P!WIhb zpP~%V_!G%#0GYXwiXjYP$`pznJPEpP&G_O$UlyDkT zmT0p3THt4g@D;AAMNfWP1LMdHK2@#uLwkpX>XG^EVpG_xD1BSti zm+ZRX&%0b%`|s@XYF+lt@CJ9~SD<)VApBNCr^|Ado$+RPi)(PEp|?qa-eDu7Q<~$J ziM1c?_IGx-0ywey(Pr;3K48a26W(PHav#{=+;;}iD)iVB^c=eE9jZc~eMYZ}MQ)~I zG5McNA-iC&vypwmI_}6@%Z8nx$bPu@h7+8;j(hROb~JdiyhjJrRKXOcHEhe67#LOqF$6>jP5#3u zDJ{&zkjjEpUIZl1C_34INtRhGOMG$wle(@1P_<+V*D^++Nk9xWNeV2=jV3BRc?r`@ zMw!WW%u`%Ba}z7#3-ZfTi*B(cmZisM6cw=nHQ(X{af%C4Q&VnngP6&QDXDqMskcNx zoTAj?g3O}ClFal|18i*kvN)#;0jV3BKnTL5Mqxj@y%v0Fe63f!#Gm0kHvs5x_P5#NUbMi4(;mKlb zNsOA4E7^*qG=L@*X@UqXAn}XCCO1E&G$+-rNPjXbd!@Muqs9jYOk#q|N08_jEK*>@ E0I~WpssI20 diff --git a/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc b/FitnessSync/backend/src/models/__pycache__/segment_effort.cpython-313.pyc index 543c93caec0ae4706f696f68e2c28183757192fd..ac247bfa453140176642cbf8de122f74e2926e07 100644 GIT binary patch delta 342 zcmZqSUd_Y%nU|M~0SG3ArDcAc$orMiVKOu0Hdmow=3sV9rXt}ORwafQHYiU7%!A2^ zqR5GXc|1xCK((Bfj4|Rt29u95DymAxNCCyTO&LM9FfbHJgZVsgzDzK0k?dq1rg@BV zlUFk>b>+-WtcWkjFHbGH#g9-mQE#0J!Nixb2tE=WyHxy21)CMTw(<|U`z5&?0F zQi}^RixNvR^Yh|?RO;k#=3R^rCkwIU+X#qFC|@CRQ9!T3^CqwG1os6g7kRZC+-?HJ zOBa+}6wm>R3rWlnUtw}l$e_XdrnKS$)eR~arOhv}NKEcwv0^lse4M3R)BxmmLy$u_ fY;yBcN^?@}ip(YlvnugRFltP2`N{yKia?40bx>cL delta 175 zcmZ3@)56XBnU|M~0SG2KCuRyw|Z>e~gN%A~B*s zF>X^vkZJ~oA~7(Z2hJA{<}H$#?7}pUQF8KQrlss`iDl{W8AX%NGVfyCFu8*zpPN^B zg8PD$i@e$mZj+5!tr&GC=dqTH>VOQ>1roP7Y;yBcN^?@}ii{@zVpZZ7Vbqx5@|6Kd I6@e510LsWJV*mgE diff --git a/FitnessSync/backend/src/models/activity.py b/FitnessSync/backend/src/models/activity.py index b1dc0c7..5f6aea0 100644 --- a/FitnessSync/backend/src/models/activity.py +++ b/FitnessSync/backend/src/models/activity.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary, Float, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary, Float, ForeignKey, Boolean, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func from ..models import Base @@ -13,6 +13,7 @@ class Activity(Base): start_time = Column(DateTime, nullable=True) # Start time of the activity duration = Column(Integer, nullable=True) # Duration in seconds duration = Column(Integer, nullable=True) # Duration in seconds + last_segment_scan_timestamp = Column(DateTime, nullable=True) # Location (added for optimization) start_lat = Column(Float, nullable=True) @@ -37,7 +38,32 @@ class Activity(Base): norm_power = Column(Integer, nullable=True) # watts tss = Column(Float, nullable=True) # Training Stress Score vo2_max = Column(Float, nullable=True) # ml/kg/min + avg_temperature = Column(Float, nullable=True) # degrees Celsius + # Cycling Dynamics & Power + total_work = Column(Float, nullable=True) # Joules + intensity_factor = Column(Float, nullable=True) # IF + threshold_power = Column(Integer, nullable=True) # FTP + is_estimated_power = Column(Boolean, default=False) + avg_right_pedal_smoothness = Column(Float, nullable=True) # % + avg_left_torque_effectiveness = Column(Float, nullable=True) # % + avg_right_torque_effectiveness = Column(Float, nullable=True) # % + + # MTB Metrics + grit = Column(Float, nullable=True) + flow = Column(Float, nullable=True) + + # Health Metrics + avg_respiration_rate = Column(Float, nullable=True) + max_respiration_rate = Column(Float, nullable=True) + avg_stress = Column(Float, nullable=True) + avg_spo2 = Column(Float, nullable=True) + + # Swimming / Other + total_strokes = Column(Integer, nullable=True) + avg_stroke_distance = Column(Float, nullable=True) + + streams_json = Column(JSON, nullable=True) # Cached downsampled streams file_content = Column(LargeBinary, nullable=True) # Activity file content stored in database (base64 encoded) file_type = Column(String, nullable=True) # File type (.fit, .gpx, .tcx, etc.) download_status = Column(String, default='pending') # 'pending', 'downloaded', 'failed' diff --git a/FitnessSync/backend/src/models/segment_effort.py b/FitnessSync/backend/src/models/segment_effort.py index 32562d9..ae1209f 100644 --- a/FitnessSync/backend/src/models/segment_effort.py +++ b/FitnessSync/backend/src/models/segment_effort.py @@ -15,7 +15,11 @@ class SegmentEffort(Base): end_time = Column(DateTime, nullable=False) avg_power = Column(Integer, nullable=True) + max_power = Column(Integer, nullable=True) avg_hr = Column(Integer, nullable=True) + avg_speed = Column(Float, nullable=True) # m/s + avg_cadence = Column(Integer, nullable=True) # rpm + avg_respiration_rate = Column(Float, nullable=True) # breaths/min # Potential for ranking (1 = KOM/PR, etc.) - calculated dynamically or stored kom_rank = Column(Integer, nullable=True) diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-311.pyc index c1621f98742ba66e257662d41066c94970869943..8a90324e80748ea4abb8e1b44e68ff9bef97c4a1 100644 GIT binary patch delta 3757 zcmai1YfKy26`t#{d4-oT4o`y#!#aZ#NJ2=$(@A(NCO~4dc_9(U_6*oyJTM*tv?kvD zu~J(_ZKA2vs>-S~jT$Fa6{`HPRx2&tKSBAE8EaJNNGtKHznwPKCaX%@bH@f6Let^u zoO8ePxc8oWeDCG!fgi3EJ}M~4Bj79aP6R9Hdxhob;h${}kpYbobm6mqqlgaC;;jA> zIw<}{ziK@QrjJQJ2N<6VMAb81!5bk#v|6paS-fYcK^AeHm;z)PupGcr&L$jAa*zf6s2t zB|o!7v+k0gRX(f9q4eV6+zM1IO1X4J@gHE0f83d87%{|hQ4}%7VsU;QDiX_cX=Kza zjr&*@gQZckEY>Vbuq7%HzXxiFZvF1SAVBe z?9Y!Nv-o9xJ!y@a#jX5`8uM*)I0rTY_ySrW7Rl?x0*d1Lts9~9#Ik~h;|BzGW|rl> z!aUEaIZQAjc*A~HE#}$CY!Kie7xn;S)$9lxi9k>6D42`Y!Kgfi$stUPn3P~rk4Xa% zwSo-?a45#JHx&O7=;;bIPv8V^$g{xl(`s=rEU@87P+0Ozc>My$t3^Tp90Od4@yv1y zEU#90Z%uka?1bQ%jj;?kMrJrp2!vr;Y9-e3!N~w9_v$P=eh_wUp7pR36Rckd-eNV^ znqAj@^_f{t@UIx@W>=Y4Tfsu63$Sik4El4K> zaBiB7sHTh&bUZs7Q;6fm0<4!8()*H&fygY&GHS`roOZDTSoC`t7Vm0)dH`?4&xVEV z;TSgEHK&yV?+u27;mJ&z)Y^}wbl4_g?vLPno5jYRK2-EiqszHNPa++OI5gWm$ zIUL*je7qTtY}zKcm?J-dqxDl_tE6-#c7D~jc4E_LTG6eZ0HL~O(_~KN8uGJJ1dvaA zQ~6`a(E4pb(Vekh=gS2RN~7y?S+!DT`%{}@?@5;R$Ynj7Ro46M@$zJ>>bP8WJe5aS8-5%6O)P#}uI*N8 zyI&EpHSq}b^}*>*v(T}TmR!ywvhYI({%;iPVt z+~ii8+;K8aZpW{b>0X8IO%Z6;d)k@bH*~uzSR-(DMSzB6_O!p~tp9F21 zTRFc8p{o_@)CSd+q`DG5nL4LX=OpSJgmI&*Xv1|g>AER-=VTYJxcC%799_`G`<`BW za#89Wl8+24M~0;%!+>Abjp@F_jM4og&0>JEx{&S_?#}35rx}1tXhA0}E`#+F8o<_= z0q)dY!p2L;g^e-Oj5QX~E$CMI@N~-_4sTFrlGK@mS*9Ec<&Y=`%=QAhgkCnkXqLtp zd5~2GSt(}c;;l~|PaKJM+1{_%`z2_w=w2M9QlU<6P#sCCBhmZci*}+B^s?whk#x-~ z5BQV;pVSzNH#}{A(wv~>#$KhdSArG@!8Svbiia0BsP-h)o@jZ0E-^fr33+f*8Jv{p zxy%Ms%XGg&_e;=b=03auAEP1koco58u8hn5H!!(|NHd)aebXJ4tmX z1exkps9uTcg(#!w8rpDslWwo%6J+D!MqFt z+uGJjC0ie~&7QuvUOMWK51)mtk?4+?LUnFXJxQu(4Yt^+P)><*zTL7Mx<3|gOIl7! zmXj%c!SR|Du}!4vqp2FAu_Hy~S==a1R}7o=haWi}I96Nb`ZlG$Ek)$kxzSc*v-V40 z%}?4oC0plOf5M;e!|!>uF-dnyGyt&-BAsw0X_o};Iwy@@fd*#(1ME*~rLjJ}PHuIa z{o3)RW4%@Ga48+Gm0nzJt35Jx=FQtwP^TS&DN;KZGsk0wy-FXJuVZQcuJS{@H*em2 zNSz|JvpI7_XV|Osf&Dto-qD?d4DRER0ky$Cr2f~ePJB?}9{)wD%R&6!>}nwsm&#n# z2DvyNWVu@JvgXQG63g`n%akVD^1)ng(YsD(%cn^!w<9cfXtE>EeT+=zTHJOrX*al! zi7wMX7k)(2NV~@#Bkcl>;MY6+qXJYd-@X^&>V~IT#52KjGoA^!xo1HAGx4vc8u%~f zURK4k!qVf}zRLjeS1`GX$u%H!QRY?#8E##8s_`5a4FW$sEAZpk?gl0vOm1T0$Akf* z8vKD^h*8N~-jGV-8+uCoT~&FJcJr2ED@=a=Y`u!ncVrf#!6AVE|1t#sBL7pcd@fE} zYYNIhqAU3!VEl+UT-S`2#O1o4F}xDh;9C;lDMrG}hR06={x&8SOlmRFo&h)|+KjZQ zj|m=!)bjX!PUj5A%!k-+{+EE^1V>WnS>>T7bjpWNaon6DFkd~HAQPXg)qiz8QK+E{i?Riz2&h5O1j zT_$$rvoQD>3=l#-RnV()0TjwrX6$3gN>BBuDC#q6@TH#z+)@d*RGkdnDF}b53NvZ` z-6_S}*l)Xe=wX_$X__AsrsLCv^J*+wYBAwI&4Px2Aa@7H1#XZ}j# z$sY2$T`*$ePgPLVWojc;KSwX*F7l|Q74`L__yOE6*GB1s%96(#_FBiy1X06D8ZJs% zV$0@|9w%i;N%^7EVp2g+N-Bs_W=+}L;f-tn*^pE8);cmTETsrRA^sIvx{l= z73HHi)Y`S?>JnXD(KsqA=XTW>RdbbMW|@?UXXU1sElfNNejQv1idAl@$}LM9oMqO1 z#~tnxxmt;v^T~cr+?6D_C3m)c~W~=U0 zp~Frw?}U_hB1$niPOxFIKCs@iqOwNO(j-}$LiUh-H-3l6RZCoTlmdH>3l`DhkSvan z{Rzwuwap~12Vuz?ELqX5><{}F?2EdvFYH6^UgQ}>T|*-DO3;g-H>#qXpdKu{m)z)V zx9I4R96g8|4xN=#J1=s_CGI%FeQ-ELZ{W}uB>3h!w5>tgLaqppN$?ng$8eM;&=ljGlCc`$?is@W4i3^j!>+^9 zHCP(veLJ!8c02Rx)go3-*-2p7uH>MwsM{&^Ma)+x z_4Vn>RZRlstLcjR?AX<8hHwZ7&yjh40(M_5Qq&i#SBn|Kj{w4t%Df`3;Q;eUmEB-s z9+{L42Lz4Dm63r9h#ANQlTFqqk$a)xfH_zL28j6fxUDgws|)L@7x`-Z$Do(S_!&q#zJ zUk5u@{VWFfYTGV;ami#RfzJ8OJdZ-ZlY=Zf$ke?I49Z?Cd@4+v3@SZleYBu8C4(a2 z2yIOoq;QRgLOUURvQCoeN+@P*j$2ZV1-R&Yl6)nxA=I~Gy)UJCU4BaA=5^J&E6 z903CXNI)*{WZUH|d6L3iN)9Qn=Xp%UsLwMo(qGB@@FS9~-$2iQXg&yv4gu|4N|eGT zT)e<6v@GU*+rE$}9mcg1YyXA;ApBUw15 IKS5sCKi$1?P5=M^ diff --git a/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/parsers.cpython-313.pyc index 66fbf510e291a063a9b8e957012fb151ec37f4c2..28af5fab97644c132069ce79cef19d3cb5319625 100644 GIT binary patch delta 2537 zcmZuzTTC1275@J*#$c|#g}cqJ2Z+foBoIP?1(!>>8{;_6CbWs;%NRR=hm0p7S#9gA z_8||Is;IMDDGzOowCWmFDz4H8M|~*KK5UTEFcZ5wbhJ`Xt4e(HN`Ca*U>L#MA;P=85q z!r?+jr5g7Ow+k<+$0%P42~Ufwg$j+1Xurs7c8#t8w2y_`#Wez}X)hh4{BDFig|JTF z`>iIS66Um3xKcP;bi`2Eg-Ia@Ik6i>D?Hd;DWtSDxI&oKzJql+AKhy{mEfZzK1U8> z8_^i$_zNQVZ4RI2@UJ=iJESfUZX1m6y7j&15DG$<;P@W|9l8xJ9MySr1~h&Np{QQi zDLzDb^g^Y^I0?*7#%RR2TUC>MZnbO z7z*}jh#G!ga)|cm_dA20^lF%b$It@k@FP@$8qt`_!3d{IKE+1in-WWh*<&n6oz#9z zBeEcvnQ_qQ!StRK!XebJvY-&QS!Qg-LQ83rrG#gemKn~+U*#C3C>V+HzG#3^G%+R? zi?C5)s`OgC39@ol0y+YE0yKeU09%csce0_tOQLylToDdCnJ!Mxyh3V^S=8F;O4GvHnQF z`=*2GfXFxGjRjaZ6qSJ}%Psj9BOk$#a#xV+g6pmnkV$eUav~We*G)v@+s4S9%$Z0B zZ%&Z2gNYju9+mzQn}o|{L#GPJh!hpHs!(B0Q7x}$=D_mg*#T;6Khwx`AP5ecI(lULuwsL41X`}w6=lT^4^#6)wtL_ zDYr~XEmPZVo$+;cjTHx6nYPnws{I{nW!s2k8v!%1rY~pOCX%Wh`f##(bw;LpB)aFJ zd()fl+;HD-7Xu;DznG<$b_>xYcH%9|rbRp-lqVQzf=QZk_10x=r^E#|F|-YP)JyaU zneLb9{2~yAU z3y=)8m?xL=4T6@}-Z5HsJdM9*AF$%t3-ahiY4qY=ww7$`kQfZ-v*mjutr@a(KSa%7H{SAYdc_Nu zWygZ#SlDZ=Kb}1@Azrzf@2x?iyJdPnq6gBvOpiQwVASWpaIBseqY)g$a3XilVPv-6 zox3xa?D?kQ`169YV`j1b{TF7`+LusoTMvI}U$v*YWNS~x+OyNzo;!4@(oD+-8>9Eh z0Zz4MZ2Pj<;fiN$F7Z4oxzBA!jZ6qRLsM`h#_*Tm9hOsR~#gooE33Q`KNOERUOt!2J0ABJh0Nt#zu4 zx3C77tsdEMzuv(&r&LpQqO9HI}L-vRoyZ50;Kx%-fwjO&`h5p z&mRwgvjjc_uvKvIHwSSI07?M|Zzpbvh-#i&SmwDO5!(d<7YUHEId~@{E+9OuG2yWA zubO6j>t22BS1Np2*lDn-Z-I3~C^vtBzY^}5TMMU%w?p`wxgDaA5T9W_cKRkfw6Krc9=Qk_$wsl-bayRy?oK2)5l_U0&&kkU(MFll?}uJ$+I_y4|c z=9|&(e(yi&Q$1HI6#!p9kIW?gY~@`lkp z>9j0L0pPa*bf~CDNm(^&kjX3BP(`~^uY+;4qIexgEt&|){v3dS^wj!d^E+9FuJTa+ zC>mFO0@dg@rT(%epjH7Z)d@*k05CughQWfmPaS~9Gb;|`poi9jIBcyMYLz03ibW~a zGBlvWrbaYT^C{Gz-)s8o=twwPqGq`gz4!rFg0UqHo)FE~8XHC2$qG2q)vrJ|+i!QeSETQ<^`bqn9^FPMk2&Q~*zi)^pJ_L4A;=FJ7OE8{$7jG5b6mB@4o zOy{n4PoG=b(mqP^p&33nTVxVtDR4s{+zD-m_`tO23Jb1qCUDyCM3L>|@7&E$M|h4& zfw?R)T>{gUzf@#;(ztF24#UIHUWlKX7yS!@e_>6sp?|6$X@09_#L5)%;WSFL& zRR%1Eso9E{sE7%QPkn^5mf-*q$bhq4)FAbOcz)s~sU<2SAzj?cDn+J4U^>#dK#_SD zH{pWU;Li2!>-@-+I1m&Df|=NAGMmgLil$yZI*U&amyKoBB4ZO6TW+aR<$;56C%K*E zM`uKLL~uvWwmey6`}xHD|85yC=7e6jW7)RwH%G_B{&Asy{A{&5MU$PMj#gItr^(zf zKCx#vAZThnp+Rj6hRRf)gy5WJ#dk6RvGla&jhCl#QOV*6x~=O-FXFfGGo4QdvKM?+ zblz~yD%sYn2;0wN{#1R+W}-ilLqes8Nc+uloWI{H$EEvLg%4Km_sFnvP^Iu`Wd|x6 z>j&CX>J)=CeZbWF?DT<_ACS95)U6qBEwrZM&G_)aWADNza%5 zNI&Baig3py(%q&xRVTSpDJ~r-69{zzL@I&k5J+}pX{SS#4AKN*e_$9w`wQ{D^N%E3 zCL!^~dG~&Q@4fr(eeY5CZ~HAjFq;hotk(zm*e&O~mc1me!!)dq&j5If;E73+BPKN* zd6Sr=I6X)4T29N;9L+O)lSfs>V4C5WoAjiPYvP+gM)P{!Fs<1n*K)d>+DRj)nIO6i zKe<8vluVygbm52?yBrg*DHi^!C;-KCAucS26-MC2MIjNP8aogK130EXehS!I1V^SQ zM9wr61oviYhO7$&)AoErisCdWEh1(#(h3zIb^vs;*pMfw%pO=9Sf{zKbq*e<4yooZl+ zagqV)?~GUa5u@)=H;oc0qu5^cjiDIWz>!Y+p1##|lvpOu|1)JmCQ&v1(k!F|=}{yI z5`RQGWZH;4Wk&ifv;|pSVUUG0&8m(CCo<-2YOEKVT71VibIJ<;&B(+VX0=#lnH1XU zTctV664bS73IYbyEVzza!+Zd5|54)v)HbT9x?l~Cs{#K7hg!AY2)M=ry$v4TGuHy! zNQdZP+J7t)q)H{2hIiORb9xiZLrPAu(@il=r-syHcXJ5A;p5b z-um|cgHtDV^66k-F>c3Akcds$8~!1chJU>O=R{rKU`ytW4YqLA4IIdh9JshCC$b=$ z`f0dO3t;dmz!`9H*6JC6PsfQ|D%YAu41Ba~wR{ADjgVC}b#EgfX6&0aQy#7vtR0(q zKs&e47S6T=JwWy81)t3-=0k4efwLAPkQX00oCdJ?uDAMb=IoQKs7WHp_>S(0>;c{S`hIy(oreqkaRAA`!3A=I zs2HT5S@uw}yH)zDG zfql!SvZFmWR&?ym7%B|W?9XUrD~kOQ=tD;+si zWG71OMCNGO;gQ1Dsjs@;p2#^@rtVJV`|gCYA=&QCoCAk$Z^rbY&G}|Bd+>Jpjdao0 zRkC$eh<^Q^e_)UqlC8~e4riI$V{eSDTLT4aKyG&2rpuDCd4HNA9PZ4BYA-EoN;S8?A)aJ-tOwZ<)0(kKgWpo6SzH1nvgb*cIl)NXFhh>*HJO28WGk0=1^7+zh3RN%>SAF^%pJ^!?+@e# z3QO0sI(grb3PE&^YSzZrE}TEe!g z9MRbDf>{dy9_eR~<#izRZ_pM<7>Y*h_swMaK~4xuqVPhm!4nY1DRr zDq9`WcWgWRi>BVP)mPB@1bpQv#`;(kigA&~)eaJTYpD~^OByLm{X30_+@?_lgtdh< zTkjMYl&GXtgrT9nIBKiLH%U&Lg5e}ZBkCw|o`tc5;k32hOX>+kBW4NgL9XR!71O@f zX2MH#0}0ZEpb=IVq6DkhXpo@!sX*Xf(}KRKuFt`I6f#SD z4GtJ%VE)=o64>EPLjh4-6Q}$3rdgBI-=*-BDcv^Qd2y-h!1G)X50=63 zz7Xew^FolHTM)0+dZ&O3Ep!5S`K0tadU7Zgp56M8Via3&mI zw zo8$ntpaYTl#e}FB#d$FtubGews^cUMNBYowzDWcmd76|)Ewj^Qm+u4D3+t{IR`(ZO zhf1zP850~TXh-@{@)_G!@EMD+uAAwqj#x5?Jl@&WESB> z+Z?jVmF+8noX&jI?HJH!y_1E2Asvyt4F>&4U|ZR}ZE;ByGz8jGiaE zP14bp-Dw=BfCp#+pW13=aE%lS`n5uZ6W4?=kPiv<+^el!z=u=?7)gE?z>UUVMPr~l zD?|gh&ysX&g}^X>wfcN&uRaUs&KJJ+^>FFjg(|%b6g54xVKrGH;PF^o8-B{J9sk8Q kRPu9x^hwM2*$WVo!o=sJQC_G<8DYyFQGze-da_Ch>@kbmMTT9`p`ZFQTkT3yBFI@3hK%? zo|&ERo4wiDy>Iv4ZMJ@Cv6uiJQ}fX{@Z7iVrot?-@p!Tii_d@z1}O;)>Llt47^Eeg zte0q+ky*KBNaxi8i5SvL`YZZDgT$Nx&1^|Pk9ERV52#yIYsnmqsqv|}Ivt5iC03Eu zNo72yt0Dm)32FR1X{kYDj=zpdbc%+$5kna0DqA`iJ&X zW)x*y9@SL=0x-f=^qhrEqHB69GU|8Yf4+s#!uNXvw%)AE=_AL+qtqWpzo{NpNvQak(mv4+Eh!02LQ?Z1qHxe{A`R5s9}&Y zOI(VROeu@nRPh6#60{FRSia-clocAFb#rS{HfY>t+oAn^7TP82s79cO(46D7wf@06 zz3rIafgdNdN~Tdg2~n;?tCqpvWt+k6QpQb@Jd%x6V>4UXhod%q1e0@>>uj6lAzx)a zM%MNW$A<42j+6_U6gMV%?XFTWlG1t@A> z2V@}_5wyBsK|s)ky0GA;NlX^lA*4j#a3lYo!fvojwIUNZd&o`U>(#&{FNlotk~4fiiK1Dr$sTw zZ^D4;*}(5pjn36o&e|@#M$9|18D^F&693_r8%1(0(D*J9&_jOSIlRHKb);#^iE50R6-Bb7VZ|{+%47vVXn1tgLk_HQ0@= zxmsuI6&qGd%sZ2^GOdu?r^F7$$5dG<>F~|P48@hCS~8uDj!nv8MM)?n4o}OGSYlk2 z$MMXC_^2F7;`?o)WV|HDhu>Bsqr(c`Lr^j#6iHSh7p6;kyigfSIT=%M#X-rak*L$? zj=8T@*-2dV#pt^VIo%~TmbiE^t~S>zq$d?0@fj-jDf-)dhzg)C?giegCe-LyWFm11 zua=1=CdXCu7PkZCxFd|liPpGXND>BW2u1QSjlLE}b?YoTZ)s)LS>O$z>z2b^lyPtN z%|c+`I>1`d|5CxfCw+2FXw03+3%k>%r!25H<=!YX9a!!w9PH2A1`4);v>xL{AL_K8 z8|b`sCMV9FTR6AaJ|D?Oe&z$Kbsc#=RNzCamJp8f_|jZa^rp>4yCcm!bpogR(`0u4 zCzo$t&O3qyM{pe+FuETTk?tv?U#+286A(PB+2PGtpe+k+)^l z+p^|v%r)kEZU=A9F8Y>wmZJB2@73qsp&!L#kHlkn@pwTzo}r7pn04If$aEC_NS*h*M-75#3yETZsc_;Imd(QdY zd(PZ*=FHsQV!!*Q%VkGwd=rNJOT82>GIn6urSTcb@1`B1Y{vkDYV^U$m1drYF@5=wCQ| zNDq^-e3HncDWb~BY$2V>CQm9^SxpWX26aCeWmQvhDNRm}7RF^#)`vi^%0oFhuO*o+ zeM-p>CAE|~lAd1WqwBbw!^u4G?Ea$$)0^B^bdV3ctVNk(acJU`8A4k9@;rlQ9A{C| z^F)(DN07wrmmKsmue?SgOt1S>X-#=c(I%3Esq_d7m&H7m&FbE#yoc^y8NXir&$uNo z^_dDz0xpRcpoYFjQmGzNv(SAq9oN+UD)pN6}8GF_9v9Im*oHc)pu5l~k-NwL-O?Nm$f|q5!hMsWN<1n3aCh<=Cle2w*#2D>N;IL7$VjN0V z?Ad!%?18c+uuTsUfm5?*v2w+OP`P63u54B!bzrJJJeC)a7zHf8x>&#lfjV8SxP@+V zb()R*(44EYW(`}VTU1$9l|r8EWD@D7MOU-AoZF4`M^}@jn`zZsJT1inj@{VSi$4vN zxNc0_+@HHjX0&?!CF}jb3ztmy=~nl#sx|YmhM6aU)kIe%YlqPN|f%B*%^b^I4= zEwDEHJF5%-N`G<3CwsYaBqdb&cxXtERw!3eo+T|TEIi9xY1`F{)tA_@e1zkz{7j#{ zuZ6qY4D-7!_IOaZyFL>43bSs3X=c3w)652Wn75kZ?UvbfINn}-P!;1G6<-P!Jay6m zNWeh`x@kP6Xc9*tLF6a{6)Hen{OG#q0Z$CSUo3cBd`k;ZdI6v@>3qI0PF?}g3Zj|y z5ltqfhhFzJd-gMxBpACM0?u*zz!x&I-A^C;Bz&dV=>Ndfy{^1=WIRQNR0->roRS~Y zWK{~1<(Q!`jGAc$Hb2C^a|oX70qCQTYQ@QVm~IAG4s#2L5Gy&(K(}U7s+MG}mzY4e z8{ka_l4Utg-Ol

*whL$>=ud$gDNy(}pJ*cEOrzVNcN*MsFHGl!oi@XT>kV7ciEK zFGkjIuP?{3bV%d`_#6S)2eAB#10a?U&13`vzDW7x2H8x7h`}RiqhH3>V!ilVY!pwn z1F8I$+hMj5pbMZMV1R+nD|t;f4HgPnqB8jtL^{A${_OlP1AxDRdYi%2(pBO-0&CJs z&}tPTCA0)Jf*_UX_jL|`4;FfH39Wb>7%Q6rR~1du_*Q>@j z%%LPX6bT_xu%?F!QYat=p-KqJh$ELof+GP-pp`%zdf@`D=po|7d%MIom)@Asd^_*G zZ{ECl^LDC>wYm1syk0kfrYcr& zeOy*Xy%8Xoayq9kGZx}AO2jvaOEC5*8vnM?tZ_Wm+b$i;JX8Gal@_0wflO-6*hmWmuIxkefEX;Txa<{;@Ab&vNQN z#kc(@Xg~kjKjE-X!vFA39FM_L(`o32ku4V3S&-NiKhrtvu(B}5mpX^ys5Rb&Gi;IH zyuNo*KY2%f@QxgKM{C~EI_Y#hNx$r=3x57Z=U3w4;V(SfJ-<1GYTIbzJ&SnX!dCr* z=ZA-f4<^`F<539leVXu#JLk^K`h>l)4ixr$?t~!k1;evb^4_G3nkgAH`+^AbeZQ3G zcJ8Y*(Oq5d=@UFH7tB9W`?WdD#?t_%R9?=OG=X8Z*aZLslOFxIR>t}7Y9GB=b@h71 zct28}K)_2j#X_;n&Y_6KZ8}!6xsuM<<9sSSJpL4@G}-q0402vX4wTas!xRx^kfYe_ zqFmrN!Wwz; ziatsB=wLrBSDzicMro>Ai=7lMHREU`iCw}yb%X>$^N2|lt)xkIFt``9GItqf21blq zvLXILe2m_%UW>m*bHhloPC1FQF@$l11qAeIirGTRl&%zuIhF_cbHebqwUge7GX#9; z41CJA8oe&G%kZ`|Aj+4th`&q5(r>{renYz)uw0@eEzIyDOsSO3>u@v5$?xV&7u?r; zadj1nqX0(x)W|knM*0;!vz`NsftE&Se=CpWi)ff;8(^R?jEnrl$fuhM72w7DAK*A~ z2jA+cxq5F-9=KvPS8U5sm!1=7piWxw;TGYZHNW4Ysjo@I8~(m6bkG*5wMDjE*sW9j E11!v7O#lD@ diff --git a/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc b/FitnessSync/backend/src/services/__pycache__/scheduler.cpython-313.pyc index 0fba0b19d22d2f0f48b97b127ed311a8e536996a..675dca3be9ff951911f8cfe4fbfa491ea8b0716c 100644 GIT binary patch delta 1912 zcmah~T}%{L6ux(MXJKb|R#w<$cVXG#$AtkGQP3JfwUEkREwL`vrjiW9GT`92L+%V% zXi{xcjV3ip?u)5ud@`v?leXC>lO{ENk*B8CHj)WRO_RR(B-qrZNp0_$WueA49m2;w zKlhyPo_p@$X5Z7{&=XM<5c`BiO&^h#LNWYksrhO=LL$Ra5*@B4)x$9o8;+B>gvLBa zyO!=mGBtZKY(a?B#!v&w3JH`A?BzCe$+2Vu+FKM5siPb=aDbZl{0XuR_Ea!%;$$wb zs5x7o*X;#mLd~6;(hWnIF(;fFFcEE5C6=ZbW==H}X45R!7zLwd+4^bK)|6TEj7C78 zTM9?$4o|6=~h936&1LkvF^L?a<6_$9*1wB>B<_%U znbvy9uH_thD2A^nmy`!$+dZ-E2Ql+S^<%D+-V-m1kA0|i_to;1>W-`475bCdzpZsO z)p%p^2bf?W zmu>NM1V)aHxSUDY+qG5elrlA!&lNMxFlPtbRvG*2axbx;MD(wCJ-$(>PaMa1rm#@+ z3?JX(u~{UdLe5!$!vI@HoCMJ=nzu*>auK(3Q*31cV@OF_>9@&tyiEU0epUh1aA$!# z21uITuIoA521@tj2VvC(02fTg03eU!(et+BoiI& z294d;m#O9K#(ZT~$Iou_usJ==TIXFR7i$9h9KThXiR1&YY_f66drq4$EDw;&%)lNk zvc~B*Qp+nr%soXVScBE$S|o%Y_!Hmz6W8|N_cxvMJ@w9WIJSmdBs>z6_k(rs``z$= E1EogL+5i9m delta 1429 zcmYk6O>7%g5P;v?_4;RB|0OZD<2YXI#Ocb32}vWNrb$V{UrL&)iKbnR@Mbla_s z-fkMfftCXm6-W?`gpjIANT>%SgphnfTo5?`w?cuUUVm?_53MmPg%vp2Avo?& zQxy#VJ5+7n@n>nVg!<lj(A%s!cugks~nfx7!eXL#8ZikA$go?j~hQ3MM6*?ds`yjZRC*jh~(9pyCeQ={g9q;Gj@1dbb z7PjIVof#CbPba1)gdZm48CBZyC1?7it*pfLTYVC0R1w#M^336;+?f!9PxTQ7GJ6qteaJH1aD;b50@hm;r{50Ma26AZfG6F}(5$lZ&_6mwPO^%lj zgk59oC3q`Ycz%wnRH+s91lp9*hTqz<<%md$%L;y4cYhjwOse!ub30l0&>~bask7~z zr!auq&OAmD*Z34*@@xh%i#o|{R97rVFjiOqWm#vns$J9gQ7nU2BZzhdhfu^aSQ-Aw zbk0rV@(hRSYbWi<++AZey}?$TfL*oD^MSAHtz%cYu|sXk$N$|z_DiY2i@iCz(bRjF zC|zxSmwil^+6O;r7<&_sG!d^M+T~VIbQ|bt<$^~sw~NbsWqCO8L{@-ku0%hFqq(o5 z*ctaBai0&Cga2|zE)Ag4Ep;4M!-!GDX+(v?5skX-c$OOtldYp1*EGwn+J>%GZJQa( z>vql3v@Z!$(WQXs+y}l^ER1}G>=KS`#N93;$8HC@e+_hh_QI_|-(~NvXF;IJZQ}BrhlDb>4)nek Ha3lW@Yh^sw 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 50893ad8f470b71e307c3065b6affbbc5a81dbb9..845e2eb4fc201ba268e3bea170befd10e7e24b7e 100644 GIT binary patch delta 7597 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 UoG=y9*TZKb{>yJ@6+P|$0Dj!HZU6uP delta 3304 zcmZ`*Yiv}<6`p(dKKK3fzPz^AU|?676P1T3BRF8%1 z;{42qq?hYTSTHld7A|9Om{ZsjNV6ywgG&iOaX_&OZ*b%bZj#~1aX!ROi6k!h$rIky z!S^9U&^-kO2q}Wj%u|@32I}e@=OR)>I8ZJ~04-q)*(d~-@SD*DbD+x*^*~`2AuJBq z3X~oqQ85~f7HEV9oP{|S$*w@i4;C-jU`QBExxmywy#-6taLcQ6H`V?lqeTa~3(E2b zg#u+a?-Xda|d@A~ytk|mU!q(Tp48vyFi}u*) zo`MPB7dy5AI zp`}P`Fr0erJZCCMek=uF5{shZ!p@86humU)`;g)`oTr_jw=+K*VMfqN%RV&10B<)I zv=8)Ja!!hudltpnTa*_9*(6sr&fORC#$UW%d@^_*SdNi9a)3Cj zKF>l;K~u8P8e@bTJFT586D8;EtI0$A^<^%t1TL;cDA4$3txzQ0hlYkzO4=-#ZvE6T zmHfe>axPdVb$m>Z z>iFK0_o~LLay*hfISEB$H@3P?v5j}CzO`=4QugjrYiAjMx2~!)%-;+1Fz?~vjKZLO z#{$1+ms#@6Q|ZC%Kw$}`Sq1I6;rB8OhXsWv$Nh2M0W*=#HaQjPZG;`0(KKwhI=9Ji zDKhY#|HF45W_Yy~bZa}=SW&vx0*x_iNe>^@?37stknrS_WD{kWnX-k>Iw_c%I3=kk zwH?G3FK;~v+^m>9(bs)!M9T_lZ*TWuEi2M5Wk@HINBR}YlJhfQrfIH{kLp*Fp)!Zq z1w{I13`tg2JA)b5(i~#s&?8@PVhwq(@)D_D5?S}iTQV_ncIA|OdCRqpSGLYnG-bTa zV`5G~{?-{^%lQ)kv%Z#$uVqYnzm5E{YNdsd*OGr$m5~k0W6?l3 z=aJpkrwHIVg&dM)t43ca^2_Ca3Q~hK>g@!d_rr0@(8$#l^~@UbctwJFm+*d0q_zi-3xW^~|d+LR0@yc#Y=GThi{KsGC)!oI`KKZGF18D2#;eB-=g zHT;;vj`>w1Kw>qqeI<%u1YtF#&pjFNC^!^x6pX?p#XTJ~eq@9}Pk4bI8>K-W%4-2P zLVi?}Xo@Hc9%dX-_<4y%O*V={I6CTpwjH87$+FsNz7$>;o_tyxI#X;076#4ktg#GA zg>fn72hI0S^Wu7?dBMT`X4lVfCfeyL6B$0e}aRGFi1NA(gf_l!CtlpH0?p- z|58|}7W?QjYsEKX(~zcC93@9qwy&jO+xzW4Q!hZ@hZpGh+xmoPrh8UaQ^QA9Evc&- zTmxyX09hW2q)#Q6nxmCjN$F4P$yAS;h%Lzf4k~LF!3e3Vd)by{j%7J2scj(_>L$sy z`cgg}10mw6jEVW8Y$m^{uM{SMnXV;&slVwLP^3DC4l{Cd9>=!kLgaT1{bgSTBInzt zj$c@Jxq3FxlnFHDP|#6k!UXe(yxMrUl4g16p|A0Qukl*^+H14E4H@5t+v^|rcF*{B zf9VfTbdb7LTN`o~M{hE|2n<$Cg1;NMkW?SjKCx1;S++T}3`yy&_pR z7ql$&oL-H_E5T*?-XlXvU3(L#d|RT=PD02>;2h=B2xR%A{i$w6dyCE)B~3FZp8|Q0 zW1z{enyc4B3Ze8NpuUU#At6u6ggR+IYaio3_mrGVpKqT$bN0+-&up-I)>D)5)Z~yw zj+ijE@pJNVQ?>K!M=t-E-E`oNgpVw1Y1@AuwB?)QOLRt4TWbNxa)*Y7j%aHrO*_3t z6Itt}&`g2aWcCqLTi#ADD*^BW)Ak4-xmV;dD|go74f#KRWw{0bx2n3Q}yjbeSNB?U8Kx*3Z~EYkW1|$ b<_fvl{&am8#{_cd|Kn4W|NI53W+eU#Uy9B! diff --git a/FitnessSync/backend/src/services/parsers.py b/FitnessSync/backend/src/services/parsers.py index e3d7e1f..4131ed9 100644 --- a/FitnessSync/backend/src/services/parsers.py +++ b/FitnessSync/backend/src/services/parsers.py @@ -36,51 +36,100 @@ def extract_timestamps_from_file(file_content: bytes, file_type: str) -> List[Op return data['timestamps'] def _extract_data_from_fit(file_content: bytes) -> Dict[str, List[Any]]: - data = {'points': [], 'timestamps': [], 'heart_rate': [], 'power': [], 'speed': [], 'cadence': []} + data = { + 'points': [], 'timestamps': [], 'heart_rate': [], 'power': [], + 'speed': [], 'cadence': [], 'temperature': [], 'distance': [], + 'respiration_rate': [], + 'session': {} # New key for summary data + } 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': - # We only collect data if position is valid, to keep streams aligned with points? - # Or should we collect everything and align by index? - # Usually points extraction filtered by lat/lon. If we want aligned arrays, we must apply same filter. - - if frame.has_field('position_lat') and frame.has_field('position_long'): - lat_sc = frame.get_value('position_lat') - lon_sc = frame.get_value('position_long') + if frame.frame_type == fitdecode.FIT_FRAME_DATA: + if frame.name == 'record': + # We only collect data if position is valid, to keep streams aligned with points? + # Or should we collect everything and align by index? + # Usually points extraction filtered by lat/lon. If we want aligned arrays, we must apply same filter. - 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) + if frame.has_field('position_lat') and frame.has_field('position_long'): + lat_sc = frame.get_value('position_lat') + lon_sc = frame.get_value('position_long') - ele = None - if frame.has_field('enhanced_altitude'): - ele = frame.get_value('enhanced_altitude') - elif frame.has_field('altitude'): - ele = frame.get_value('altitude') + 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) - data['points'].append([lon, lat, ele] if ele is not None else [lon, lat]) - - # Timestamps - ts = frame.get_value('timestamp') if frame.has_field('timestamp') else None - data['timestamps'].append(ts) - - # Speed - speed = frame.get_value('enhanced_speed') if frame.has_field('enhanced_speed') else frame.get_value('speed') if frame.has_field('speed') else None - data['speed'].append(speed) + ele = None + if frame.has_field('enhanced_altitude'): + ele = frame.get_value('enhanced_altitude') + elif frame.has_field('altitude'): + ele = frame.get_value('altitude') + + data['points'].append([lon, lat, ele] if ele is not None else [lon, lat]) + + # Timestamps + ts = frame.get_value('timestamp') if frame.has_field('timestamp') else None + data['timestamps'].append(ts) + + # Speed + speed = frame.get_value('enhanced_speed') if frame.has_field('enhanced_speed') else frame.get_value('speed') if frame.has_field('speed') else None + data['speed'].append(speed) - # Cadence - cad = frame.get_value('cadence') if frame.has_field('cadence') else None - data['cadence'].append(cad) - - # HR - hr = frame.get_value('heart_rate') if frame.has_field('heart_rate') else None - data['heart_rate'].append(hr) - - # Power - pwr = frame.get_value('power') if frame.has_field('power') else None - data['power'].append(pwr) + # Distance + dist = frame.get_value('distance') if frame.has_field('distance') else None + data['distance'].append(dist) + + # Cadence + cad = frame.get_value('cadence') if frame.has_field('cadence') else None + data['cadence'].append(cad) + + # HR + hr = frame.get_value('heart_rate') if frame.has_field('heart_rate') else None + data['heart_rate'].append(hr) + + # Power + pwr = frame.get_value('power') if frame.has_field('power') else None + data['power'].append(pwr) + + # Temperature + temp = frame.get_value('temperature') if frame.has_field('temperature') else None + data['temperature'].append(temp) + + # Respiration Rate + resp = frame.get_value('respiration_rate') if frame.has_field('respiration_rate') else frame.get_value('enhanced_respiration_rate') if frame.has_field('enhanced_respiration_rate') else None + if 'respiration_rate' not in data: + data['respiration_rate'] = [] + data['respiration_rate'].append(resp) + + elif frame.name == 'session': + # Extract summary fields + fields_to_extract = [ + 'total_work', 'intensity_factor', 'threshold_power', + 'avg_left_pedal_smoothness', 'avg_right_pedal_smoothness', + 'avg_left_torque_effectiveness', 'avg_right_torque_effectiveness', + 'total_grit', 'avg_flow', + 'enhanced_avg_respiration_rate', 'enhanced_max_respiration_rate', + 'avg_stress', 'avg_spo2', + 'total_strokes', 'avg_stroke_distance', + # Standard Metrics + 'max_heart_rate', 'max_speed', 'enhanced_max_speed', 'max_cadence', + 'total_ascent', 'total_descent', + 'total_training_effect', 'total_anaerobic_training_effect', + 'training_stress_score', 'normalized_power' + ] + + for field in fields_to_extract: + if frame.has_field(field): + # Handle field name mapping if needed (e.g., enhanced_ -> simple) + key = field + if field == 'total_grit': key = 'grit' + elif field == 'avg_flow': key = 'flow' + elif field == 'enhanced_avg_respiration_rate': key = 'avg_respiration_rate' + elif field == 'enhanced_max_respiration_rate': key = 'max_respiration_rate' + elif field == 'enhanced_max_speed': key = 'max_speed' + + data['session'][key] = frame.get_value(field) except Exception as e: logger.error(f"Error parsing FIT file: {e}") @@ -150,9 +199,6 @@ def _extract_data_from_tcx(file_content: bytes) -> Dict[str, List[Any]]: def _extract_points_from_tcx(file_content: bytes) -> List[List[float]]: return _extract_data_from_tcx(file_content)['points'] -def extract_timestamps_from_file(file_content: bytes, file_type: str) -> List[Optional[datetime]]: - if file_type == 'fit': - return _extract_timestamps_from_fit(file_content) - return [] + diff --git a/FitnessSync/backend/src/services/power_estimator.py b/FitnessSync/backend/src/services/power_estimator.py index c4f98c7..248d679 100644 --- a/FitnessSync/backend/src/services/power_estimator.py +++ b/FitnessSync/backend/src/services/power_estimator.py @@ -23,6 +23,96 @@ class PowerEstimatorService: self.DEFAULT_CRR = 0.005 # Typical road tire on asphalt self.DRIVETRAIN_LOSS = 0.03 # 3% loss + def calculate_power_stream(self, activity: Activity, extracted_data: Dict = None) -> List[int]: + """ + Calculate power stream based on physics model. + """ + # 1. Get Setup and Weights + bike_weight = 9.0 # Default 9kg + if activity.bike_setup and activity.bike_setup.weight_kg: + bike_weight = activity.bike_setup.weight_kg + + rider_weight = 75.0 # Default 75kg + latest_weight = self.db.query(WeightRecord).order_by(WeightRecord.date.desc()).first() + if latest_weight and latest_weight.weight: + rider_weight = latest_weight.weight + if latest_weight.unit == 'lbs': + rider_weight = rider_weight * 0.453592 + + 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 = 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 [] + + # Generate Power Stream + power_stream = [] + + for i in range(len(timestamps)): + if i >= len(speeds): + break + + t = timestamps[i] + v = speeds[i] # m/s + + # Skip if stopped + if v is None or v < 0.1: + power_stream.append(0) + continue + + # Get slope + grade = 0.0 + accel = 0.0 + + if i > 0 and i < len(speeds) - 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 + + 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 + prev_ele = elevations[i-1] if elevations and i-1 < len(elevations) and elevations[i-1] is not None else curr_ele + + d_e = next_ele - prev_ele + d_s = (v * d_t) # approx distance covers + + accel = d_v / d_t + if d_s > 1.0: # avoid div by zero/noise + grade = d_e / d_s + + # Physics Formula + f_grav = total_mass * self.GRAVITY * grade + f_roll = total_mass * self.GRAVITY * self.DEFAULT_CRR + f_aero = 0.5 * self.RHO * self.DEFAULT_CDA * (v**2) + f_acc = total_mass * accel + + f_total = f_grav + f_roll + f_aero + f_acc + + # Power = Force * Velocity + p_raw = f_total * v + + # Apply Drivetrain Loss + p_mech = p_raw / (1 - self.DRIVETRAIN_LOSS) + + if p_mech < 0: + p_mech = 0 + + power_stream.append(int(p_mech)) + + return power_stream + def estimate_power_for_activity(self, activity_id: int) -> Dict[str, any]: """ Estimate power activity streams based on physics model. @@ -35,121 +125,31 @@ class PowerEstimatorService: if not activity.file_content: raise ValueError("No file content to analyze") - # 1. Get Setup and Weights - bike_weight = 9.0 # Default 9kg - if activity.bike_setup and activity.bike_setup.weight_kg: - bike_weight = activity.bike_setup.weight_kg - - rider_weight = 75.0 # Default 75kg - # Try to find weight record closest to activity date? Or just latest? - # Latest for now. - latest_weight = self.db.query(WeightRecord).order_by(WeightRecord.date.desc()).first() - if latest_weight and latest_weight.weight_kg: - rider_weight = latest_weight.weight_kg - - total_mass = rider_weight + bike_weight + power_stream = self.calculate_power_stream(activity) - # 2. Extract Data - data = extract_activity_data(activity.file_content, activity.file_type) - # We need: Speed (m/s), Elevation (m) for Grade, Time (s) for acceleration - - 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: - raise ValueError("No speed data available") - - # Generate Power Stream - power_stream = [] - total_power = 0.0 + if not power_stream: + raise ValueError("No speed data available or empty stream") + count = 0 + total_power = 0 - # Smoothing window? Physics is noisy on raw data. - # We'll calculate point-by-point but maybe assume slight smoothing explicitly or implicitly via grade. - - # Pre-calc gradients? - # We need grade at each point. slope = d_ele / d_dist - # d_dist = speed * d_time - - for i in range(len(speeds)): - t = timestamps[i] - v = speeds[i] # m/s - - # Skip if stopped - if v is None or v < 0.1: - power_stream.append(0) - continue - - # Get slope - # Look ahead/behind for slope smoothing (e.g. +/- 5 seconds) would be better - # Simple difference for now: - grade = 0.0 - accel = 0.0 - - if i > 0 and i < len(speeds) - 1: - # Central difference - d_t = (timestamps[i+1] - timestamps[i-1]).total_seconds() - if d_t > 0: - d_v = (speeds[i+1] - speeds[i-1]) # acc - d_e = (elevations[i+1] - elevations[i-1]) if elevations else 0 - d_s = (v * d_t) # approx distance covers - - accel = d_v / d_t - if d_s > 1.0: # avoid div by zero/noise - grade = d_e / d_s - - # Physics Formula - # F_total = F_grav + F_roll + F_aero + F_acc - - # F_grav = m * g * sin(arctan(grade)) ~= m * g * grade - f_grav = total_mass * self.GRAVITY * grade - - # F_roll = m * g * cos(arctan(grade)) * Crr ~= m * g * Crr - f_roll = total_mass * self.GRAVITY * self.DEFAULT_CRR - - # F_aero = 0.5 * rho * CdA * v^2 - # Assume no wind for now - f_aero = 0.5 * self.RHO * self.DEFAULT_CDA * (v**2) - - # F_acc = m * a - f_acc = total_mass * accel - - f_total = f_grav + f_roll + f_aero + f_acc - - # Power = Force * Velocity - p_raw = f_total * v - - # Apply Drivetrain Loss - p_mech = p_raw / (1 - self.DRIVETRAIN_LOSS) - - # Power can't be negative for a human (braking/coasting = 0w output) - if p_mech < 0: - p_mech = 0 - - power_stream.append(int(p_mech)) - - total_power += p_mech + for p in power_stream: + if p > 0: + # Avg usually includes zeros... verify Garmin logic? + # Usually Avg Power includes zeros. + pass + total_power += p count += 1 avg_power = int(total_power / count) if count > 0 else 0 - # Return estimated stream and stats - # Ideally we'd update the Activity 'power' stream and 'avg_power' metric - # BUT 'extract_activity_data' reads from FILE. We can't easily write back to FIT file. - # We should store "estimated_power" in DB or separate storage? - # The prompt implies we want to USE this data. - # If we just update `Activity.avg_power`, that's easy. - # Displaying the stream might require `Activity` to support JSON storage for streams or similar. - # Current schema has `Activity.file_content`. - # Updating the FIT file is hard. - # Maybe we just return it for now, or update the scalar metrics in DB? - - # Let's update scalars. + # Update scalars. activity.avg_power = avg_power - # Max power? activity.max_power = max(power_stream) if power_stream else 0 + # Flag as estimated + activity.is_estimated_power = True + self.db.commit() return { diff --git a/FitnessSync/backend/src/services/scheduler.py b/FitnessSync/backend/src/services/scheduler.py index 4705d1f..4e6a9d5 100644 --- a/FitnessSync/backend/src/services/scheduler.py +++ b/FitnessSync/backend/src/services/scheduler.py @@ -16,7 +16,12 @@ from ..tasks.definitions import ( run_fitbit_sync_job, run_garmin_upload_job, run_health_sync_job, - run_activity_backfill_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 ) logger = logging.getLogger(__name__) @@ -35,7 +40,11 @@ class SchedulerService: 'fitbit_weight_sync': run_fitbit_sync_job, 'garmin_weight_upload': run_garmin_upload_job, 'health_sync_pending': run_health_sync_job, - 'activity_backfill_full': run_activity_backfill_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 } def start(self): @@ -75,6 +84,55 @@ class SchedulerService: ) 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() + except Exception as e: logger.error(f"Error checking default schedules: {e}") diff --git a/FitnessSync/backend/src/services/segment_matcher.py b/FitnessSync/backend/src/services/segment_matcher.py index 2270272..ccbcd00 100644 --- a/FitnessSync/backend/src/services/segment_matcher.py +++ b/FitnessSync/backend/src/services/segment_matcher.py @@ -17,7 +17,7 @@ class SegmentMatcher: def __init__(self, db: Session): self.db = db - def match_activity(self, activity: Activity, points: List[List[float]]) -> List[SegmentEffort]: + 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] @@ -41,9 +41,14 @@ class SegmentMatcher: elif act_type in ['trail_running', 'treadmill_running']: act_type = 'running' - segments = self.db.query(Segment).filter( + query = self.db.query(Segment).filter( (Segment.activity_type == activity.activity_type) | (Segment.activity_type == act_type) - ).all() + ) + + if min_created_at: + query = query.filter(Segment.created_at >= min_created_at) + + segments = query.all() matched_efforts = [] @@ -86,9 +91,6 @@ class SegmentMatcher: from ..services.parsers import extract_activity_data - if not activity.file_content: - return None - data = extract_activity_data(activity.file_content, activity.file_type) timestamps = data['timestamps'] @@ -107,19 +109,73 @@ class SegmentMatcher: # Calculate Averages avg_hr = None avg_pwr = None + max_pwr = None # Slice lists eff_hr = data['heart_rate'][start_idx : end_idx+1] - eff_pwr = data['power'][start_idx : end_idx+1] + + # Handle Power + eff_pwr = None + if activity.is_estimated_power: + # Calculate power stream on the fly + try: + from ..services.power_estimator import PowerEstimatorService + estimator = PowerEstimatorService(self.db) + # We can pass already extracted data to avoid re-parsing + full_power_stream = estimator.calculate_power_stream(activity, extracted_data=data) + if full_power_stream and len(full_power_stream) > end_idx: + eff_pwr = full_power_stream[start_idx : end_idx+1] + except Exception as e: + logger.error(f"Error calculating estimated power for segment match: {e}") + else: + # Real power + if data['power']: + eff_pwr = data['power'][start_idx : end_idx+1] + + # Fallback: If no real power found, and it's a cycling activity, try estimation + # Check if eff_pwr is all None or empty + has_real_power = eff_pwr and any(p is not None for p in eff_pwr) + + cycling_types = {'cycling', 'road_biking', 'mountain_biking', 'gravel_cycling', 'virtual_cycling', 'indoor_cycling'} + if not has_real_power and activity.activity_type in cycling_types: + try: + logger.info(f"No real power found for Activity {activity.id} ({activity.activity_type}). Attempting fallback estimation.") + from ..services.power_estimator import PowerEstimatorService + estimator = PowerEstimatorService(self.db) + full_power_stream = estimator.calculate_power_stream(activity, extracted_data=data) + if full_power_stream and len(full_power_stream) > end_idx: + eff_pwr = full_power_stream[start_idx : end_idx+1] + except Exception as e: + logger.error(f"Error calculating fallback estimated power: {e}") # Filter None valid_hr = [x for x in eff_hr if x is not None] - valid_pwr = [x for x in eff_pwr if x is not None] + valid_pwr = [x for x in eff_pwr if x is not None] if eff_pwr else [] if valid_hr: avg_hr = int(sum(valid_hr) / len(valid_hr)) if valid_pwr: avg_pwr = int(sum(valid_pwr) / len(valid_pwr)) + max_pwr = int(max(valid_pwr)) + + # New Metrics + avg_speed = None + eff_speed = data.get('speed', [])[start_idx : end_idx+1] + valid_speed = [x for x in eff_speed if x is not None] + if valid_speed: + avg_speed = sum(valid_speed) / len(valid_speed) + + avg_cadence = None + eff_cadence = data.get('cadence', [])[start_idx : end_idx+1] + valid_cadence = [x for x in eff_cadence if x is not None] + if valid_cadence: + avg_cadence = int(sum(valid_cadence) / len(valid_cadence)) + + avg_resp = None + eff_resp = data.get('respiration_rate', [])[start_idx : end_idx+1] + valid_resp = [x for x in eff_resp if x is not None] + if valid_resp: + avg_resp = sum(valid_resp) / len(valid_resp) return SegmentEffort( segment_id=segment.id, @@ -129,6 +185,10 @@ class SegmentMatcher: end_time=end_ts, avg_hr=avg_hr, avg_power=avg_pwr, + max_power=max_pwr, + avg_speed=avg_speed, + avg_cadence=avg_cadence, + avg_respiration_rate=avg_resp, kom_rank=None # Placeholder ) @@ -168,7 +228,15 @@ class SegmentMatcher: return None # For each candidate, try to trace the segment + # OPTIMIZATION: Track the furthest scanned activity index. + # If a previous candidate attempt scanned up to index J and failed, + # checking start candidates < J is redundant because they would follow the same path and fail. + max_scanned_idx = -1 + for start_idx in start_candidates: + if start_idx <= max_scanned_idx: + continue + # OPTION: We could just look for the end point later in the stream # But "Continuous Tracking" is required. @@ -246,7 +314,16 @@ class SegmentMatcher: # Track accumulated distance for this effort effort_accum_dist = 0.0 + # OPTIMIZATION: Track closest segment index to avoid O(N*M) + # Start near 0 since we matched start node + current_seg_idx = 0 + # Search window size (number of segment points to look ahead/behind) + # A generous window handles GPS jitter and fast movement + SEARCH_WINDOW = 50 + for j in range(start_idx + 1, max_search): + max_scanned_idx = j # Mark this point as visited + p = act_points[j] prev_p = act_points[j-1] @@ -256,14 +333,23 @@ class SegmentMatcher: d_end = haversine_distance(p[1], p[0], end_node[1], end_node[0]) - # Check deviation - if self._min_dist_to_segment_path(p, seg_points) > CORRIDOR_RADIUS: - # Deviated - break # Stop searching this start candidate + # Check deviation with Windowed Search + s_start = max(0, current_seg_idx - SEARCH_WINDOW) + s_end = min(len(seg_points) - 1, current_seg_idx + SEARCH_WINDOW) + + min_d, closest_idx = self._dist_and_index_to_segment(p, seg_points, s_start, s_end) + + if min_d > CORRIDOR_RADIUS: + # Deviated from the path locally. + # Since we require continuous tracking, we consider this a break. + break + + # Update tracker + current_seg_idx = closest_idx # Check for completion: # 1. Within radius of end node - # 2. Covered at least 90% of segment distance (handles loops) + # 2. Covered at least 80% of segment distance (handles loops) if d_end <= ENTRY_RADIUS: # Ensure we aren't just matching the start of a loop immediately if effort_accum_dist >= 0.8 * segment.distance: @@ -276,14 +362,36 @@ class SegmentMatcher: return None - def _min_dist_to_segment_path(self, point: List[float], seg_points: List[List[float]]) -> float: + def _dist_and_index_to_segment(self, point: List[float], seg_points: List[List[float]], start_idx: int, end_idx: int) -> Tuple[float, int]: """ - Distance from point to polyline. + Distance from point to polyline subset. + Returns (min_dist, segment_index_of_closest_segment) + segment_index i corresponds to line (points[i], points[i+1]) """ min_d = float('inf') - for i in range(len(seg_points) - 1): + closest_idx = start_idx + + # Ensure we don't go out of bounds + # range is up to len(seg_points) - 1 because we need i+1 + search_limit = min(end_idx + 1, len(seg_points) - 1) + # Fix: end_idx is inclusive for search range logic usually, but range() is exclusive. + # Calling code passed 'len - 1' as max index. + # We need to iterate i from start_idx to end_idx (inclusive of processing line starting at end_idx if valid?) + # Let's treat inputs as [start_idx, end_idx) range for loop? + # Caller passed: s_end = min(len-1, ...) + + # Let's just iterate safely + for i in range(start_idx, len(seg_points) - 1): + if i > end_idx: break + d = perpendicular_distance(point, seg_points[i], seg_points[i+1]) if d < min_d: min_d = d - return min_d + closest_idx = i + + return min_d, closest_idx + + def _min_dist_to_segment_path(self, point: List[float], seg_points: List[List[float]]) -> float: + d, _ = self._dist_and_index_to_segment(point, seg_points, 0, len(seg_points) - 1) + return d 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 496d334007f8b45c9e50d8649dbe60b74b719762..2bc71b1ca109c07f614e9fc2f2ce97bb194ae26b 100644 GIT binary patch delta 7765 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 La^MpJQIz^$@L`-T delta 1403 zcmZ9LZ%i9y7{KrC^&h3>Pia{ZhAmj=simcBWvmpKjKP>R3mwi4$a%C!skT>q@0=q> zA<;PImKgVJ$;>9x?2C!cY}Od{6B>nm)0r&Mi=T`!nTZCSiJy$V&moccCii=vzwe*t zeeUm1$;L~h=AO&t;Mh2FKq`E^_h!u*Lf(f<XUR9J-O;lxBp(F6ht{)l?ptVNTB&vy8p(Kl{M(Oai z7kxFsB)V)1vXUz(dBH;am`;Zfm_&yW({QarT{@1UlR>x66^fcnsj(SfP(p{?a*(0 zi^8<5iD7%#n835|-Z}I6)~<>(95BHJiw8b(HNZmD0m~6vlY6W|vHH18U8er9XUwbE z{M@9ijhi$<{jdio z9(#*C4|ie@EWNYK4;pZIRBP^9o`b zfj6R9G~KlwE3;^go^ytmRd{<()L4rpEJNi}`m98{Lh0 zXCW1j5-TY2AbA)r#D9hJiNBUc65QveAo8L8y=?j-cpu}J-2S$MDQ>+DmGzWkB*t&p z0wZC5BV=Kgjj#nRF&^cF;}Fkp4D1{2=Wq2}m~0%KwXmc~S*+UjK-uVvxS8WFvbUZf z9L)8&EB>AU5537w_M=M4h|7~8TPc&I=!Tm;_Y*I4CnKa0y%0@S!5c}ZIqwsi;Bu-f zFv5cAD1&a#O2slQ$SOUAs-+~I8l9kH@N?=tCq0Z|k0Hk4`~LZkwBVsnplakxC|yU) zGU!&8D$Y}yg--?|NO1Rt7^p3j8vFV2P2Tq%FBw%se@NIHzTJx-B xZ|zx||E%>-R3SM1OY_&bh97}VqucvR<=nGHxu1T=L3n1UwrnPj-#Mfa^$$C0T+{#n diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-311.pyc index 08bea3a9fb4f1e5bded98970ac4c54dd4d5c6a83..90864aec75897e0f0622e9a3e93a6963c7306d86 100644 GIT binary patch delta 1187 zcmZ{iUuauZ9LLYe&1r7a+?%$!xlNjz%rtE@O-kF@R!q?lqoYlwv+L6Ysj;VPqqT9T zw_7bYzR2v*ij^PepcCp#yW++w7p5;Nf{w7Evzrf6@Xua`3SuTch#+`Q!a#H$IG^A5 z{QjNaf%E6FOS9^>qPP)XKi`-xx{~j!+cG|FUy{?fNn$R`!TK$V2443l{@On3 zZo~K%9B=wTw^Z8voKqAt!gb3EL8z?4geRA5r9MkJuWh*ydXMk=Dz{p#)PF)`6RNK$ zJe#c9@QLREsm=1i!$wt^qrTM&9S za7WVva9kb6I$Th*zI!Ye?-Z@|>#%gLV}QN>#(z$u!TsJ){T=I$wa>t-#*|LW&2x#> z#MuR_-Y(J48?0w7$o#d}yg^CYbwSh}I}o=B&%5`(^IBvQCGN9(nl-Hc^cJ6R_K#N= zKH749o3XPkmV`I#kF~?tk;9B-At;7B9({}lSXdw~kQW#c80AQKSVZUvff}+J0@Ed-My&V}6Wea`m@gIR)bleYD^5?HoGH%Ene25D?-zJOAP+ZEv3RY%L!6g5_K8_C z3-fGUINlTvsYsXE%V6}(;hXlU9=`*x!bWepcT5DG&y-5@tXm}4E~i4(9?XQQjou*C zS#b8P4jse9UqyoFa&`ORblp}a>-H{u0zU0q$B)C2{_pT6^dAi19mpTNojK&h=_UCa8% zoy4}tt)j9Oq}GhzgTg32=?WXTd11~+9~_xuilPWnv?v1~Gw?~Kcz!o;GVjCZ`}_0# zo%1_zub=*FM!%$K0Vcl}F1$2%$GWCp@`G@%`-i}5Pq&QvTJ4~tU3mzvwS!$7hEzq!0QYcKjEa{RmFzk@_Kv zx9tvG(4!E;b$!?u{umg$4EWEZCjP1?t+?Zp{!VXdmt)E}vE{3K=$-TdvlY2W+BjH0 zv8-%-jtfS&*5w4GpF7Qx-MD2Wy+zI4Q_S}EU-Z{?1ylDNX>`;x-l1jX>U~TE zx;uLuF2|Ew>VGeF_$0G>-O<)CxP_pj!rSg|;R%2d+;8@3ZNWs1Nt8!xiitDktb5nA z72^yrqvM0c@ATBSdaCw3o{aXrm>XeV#;q|`x#N+tR2j(nWpZnzdE8LHYRaSIY!NfmzS32#U3&rCrlH($DXCgTdns9_q_U_|I-x-xzfKD C1{Q+= diff --git a/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc b/FitnessSync/backend/src/services/sync/__pycache__/weight.cpython-313.pyc index 9d06e94d14d98d21a5296d7ea045d806107d4199..f723f6b1ad54fcdce9fb62f93a35779fd358ff2b 100644 GIT binary patch delta 1104 zcmZXTe`s4(6vyw$>&wfszLzvVo@tXNOpoXfpmoqTXoyRWKFM&BQIixnmDnYQVG{mqAtG{6;1I-iFUe(1ap*YMKL+q$Kh z?x}YeE69wI-It`bs$sr&cFbdXE!Ff*Dpil=G5r~;SWrkMn|e|um7KlCSoS(@^d55Q zrZfpjQdI4x%?w~f4F>xk1JMOzL+p5Q%0t-_V}~Gng)zLkBcY61vc}9Yd`&$XeZ=x= zY{Isk5n*Pa>YeeDo;Ie657f!vfYt4yWT!}Q%Py=_M~bQA5iJu9Sq`c=I3fMNPIirQ z!uXM9Km_k-8PHL2O&dd&LK)F+-ms;UZA>WJ+5gTmfG@fHJGT0?zOT8gkGi0x`d+&!RezDGy1{P2zHm)DhF4mORx^Afz$ z{J^JzZ5Hqo|0o3UNB?nO$H79QYtr3d;^%n!(=$KB4_-cjNqyFyVxghV0bJ4dI-djP z3jL(*?v$E0^fEl-X1DsHhx=`}_S)vjPS0e>`6e|3HIb@2o zR7u!wXRcpr4a~5dPr5TSvyYbV!h49{`(*mVk2O~UDao;qnvVgW#`Rz+GDi;A32o2v zVk5u0SXnMN^5sgsQR8R%VcZiMPCOwrVS!x&S%D)0PZDCiJR-QTVBnt6i)LZ-VB+5O~+tGhB@TJm%@`G_khS!%7uEN(cql!GUMIm zayVwhAd3q|2I_dxcojZxdPmk}IEP=yIJ|}Fc(nHgA$JpU{BbPAgZtkU0l&a`0lM^z zzbNoBq3x*g(jw2FI@{f+EfwlTeo_3rfH&i5SisGA#QnAiqy^}a(byPB7^7X)i15!5 z+H#}Tpqbtk0dXnXQl)f;zmA1O8GdWF5~?kg6XBjywc2UETgVm!mIb;qt_Z8!EjT=PAVTr{O66IsPkrjNhgLa07o&&AaA$VCpX> I$PFs>4>;ZhxBvhE delta 1027 zcmZXSO=uHQ5Xax!?d!H_chfc>)-*|*rdHF>(AH|LRYKKjZKAR+sEF;_G$zm{@|vLa zjfM$oP_>?#YQrTSjTd8N z;k-7lV*`rr1XJq`c#IOfv8Gf0|DNbotntDfEl7RvLF=VX@Y(x#pkC;&;;p|Ye|BJ8m*-j zMbsLoJS5Y!_0XwOx(Z&q8mSw8xwb*V-Cw+jc zJgN+A(4 Dict[str, int]: + """ + Iterate through all local FIT files and re-run backfill logic. + """ + query = self.db_session.query(Activity).filter( + Activity.file_content != None, + Activity.file_type == 'fit' + ) + + total_count = query.count() + processed = 0 + updated = 0 + + self.logger.info(f"Reparsing {total_count} local FIT files...") + if job_id: + job_manager.update_job(job_id, message=f"Reparsing {total_count} files...", progress=0) + + activities = query.all() + for idx, activity in enumerate(activities): + if job_id and not self._check_pause(job_id): + break + + try: + self._backfill_metrics_from_file(activity) + processed += 1 + # Optimistically assume something might have updated, or we could track it. + # Since _backfill checks for None, it only updates if missing. + # But we might want to force update? The current logic only backfills. + # If we want to force parse standard metrics even if present (e.g. to overwrite), + # we'd need to change _backfill. For now, backfill is what's requested. + + except Exception as e: + self.logger.error(f"Error reparsing {activity.garmin_activity_id}: {e}") + + if idx % 10 == 0: + self.db_session.flush() # Flush periodically + if job_id: + progress = int((idx / total_count) * 100) + job_manager.update_job(job_id, progress=progress, message=f"Reparsing {idx}/{total_count}") + + self.db_session.commit() + return {"total": total_count, "processed": processed} + diff --git a/FitnessSync/backend/src/services/sync/weight.py b/FitnessSync/backend/src/services/sync/weight.py index 2d31417..81d1e60 100644 --- a/FitnessSync/backend/src/services/sync/weight.py +++ b/FitnessSync/backend/src/services/sync/weight.py @@ -87,6 +87,11 @@ class WeightSyncService: time_str = log.get('time', '12:00:00') log_dt = datetime.fromisoformat(f"{date_str}T{time_str}") + # Heuristic: If weight > 150, assume lbs and convert to kg. (150kg = 330lbs) + # Fitbit API sometimes returns user-preference units despite requesting METRIC + if weight_val > 150: + weight_val = weight_val * 0.453592 + res = update_or_create_health_metric( self.db_session, metric_type='weight', diff --git a/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc b/FitnessSync/backend/src/tasks/__pycache__/definitions.cpython-311.pyc index cde991ee82a32a546d5c33f338293f08126c5ab0..074f5c49752718efd02f0b72ad5d8e3ab6cca464 100644 GIT binary patch delta 4754 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 delta 1532 zcmaJ>O>7fK6yCMHj-7Z-9NS6kq|{J1wu$%^q7Be00kx&Tp_IZQAPwu?v9rYMjb_KG zA`Z@>QUnrGqfu@>w^WsS*$XEmddm$?t@hHKdqU#Iq3V0HCRr?%vG>!wH*e@T`Lsz5ud-m`ZOZ)c}t|0>({PCuql<$exFu+fR1RYmy?zOXIltub1#GPKxA zSo#l%<;jSh1$!!D9|C(?+T$`l2X-l9Pk>#H#OHDTOvF(D$E>B^R2oIGtUmVRv4vPt z2PG^cE+y8J8SLufS>lMA6MKof+7WPM{UvQHK6MDkCJ^!fjWJfheo_3Ls$ZMJ?leLY zp@dLIm;vxc04iZ)73c>=*6R|7a%P(z@iw|9;fA-$K8CTfSkm%7O`lZNqyEllZ6Gf% z{>XfBRnkaW$+b_@7-6AuSb)tUL(FP2%sD%Z@FBpvD#Q)Fq@4u!obT!j@#HBC%ZT5{ zuk?pV-rdk!CK-=R>;%I5s9-$E{4R7D4?B5V_<(DgF~zYeJOfOATD)7(g)MP|q{kG_03%;?L3< zbzY2?tNr)OOb?yoP4x#4;3yBnJhZs@6__iQcaez6J>N7IPhL!>+jDtg$QTOPa3Z z*gVYnw76HDEMu(HBXxTkn3&2vDEZ@VQDg6p5i%5pd(|8p3siaBcB)?x(O?CS zFhS9_2+7jxCCz{_*PpLHkHs1vh|%*e`)N4&>uwvWDo7F1H5^Jv_z2-s6U1$x!uLwP zBMdLMykfFW*&>>_=C8_YKt-1Jo76?ewR#=;Ia>oCTtmfs0{AMfs_LGS-ITw-l-djU Xi-#8;`qhi7Dk`68@Fg_n!i4_-_jyEc diff --git a/FitnessSync/backend/src/tasks/definitions.py b/FitnessSync/backend/src/tasks/definitions.py index 70587fa..983e8e0 100644 --- a/FitnessSync/backend/src/tasks/definitions.py +++ b/FitnessSync/backend/src/tasks/definitions.py @@ -8,6 +8,7 @@ from ..utils.config import config from ..models.api_token import APIToken from ..models.config import Configuration from ..services.fitbit_client import FitbitClient +from ..jobs.segment_matching_job import run_segment_matching_job logger = logging.getLogger(__name__) @@ -171,3 +172,65 @@ def run_fitbit_sync_job(job_id: str, days_back: int, db_session_factory): except Exception as e: logger.error(f"Fitbit sync job failed: {e}") job_manager.fail_job(job_id, str(e)) + +def run_reparse_local_files_job(job_id: str, db_session_factory): + """Background task wrapper for reparsing local FIT files""" + with db_session_factory() as db: + try: + garmin_client = GarminClient() + sync_app = SyncApp(db, garmin_client) + + result = sync_app.activity_sync.reparse_local_files(job_id=job_id) + job_manager.complete_job(job_id, result=result) + except Exception as e: + logger.error(f"Reparse job failed: {e}") + job_manager.fail_job(job_id, str(e)) + +def run_estimate_power_job(job_id: str, db_session_factory): + """Background task wrapper for calculating estimated power""" + with db_session_factory() as db: + try: + from ..services.power_estimator import PowerEstimatorService + from ..models.activity import Activity + + job_manager.update_job(job_id, message="Finding activities...", progress=0) + + # Find cycling activities without power data + activities = db.query(Activity).filter( + Activity.activity_type.ilike('%cycling%'), + # Check for missing power metrics + (Activity.avg_power == None) | (Activity.avg_power == 0), + Activity.file_content != None + ).all() + + total = len(activities) + processed = 0 + + estimator = PowerEstimatorService(db) + + for idx, activity in enumerate(activities): + if job_manager.should_cancel(job_id): + break + + try: + estimator.estimate_power_for_activity(activity.id) + processed += 1 + except Exception as e: + # Log but continue + logger.warning(f"Failed to estimate power for {activity.id}: {e}") + + if idx % 5 == 0: + progress = int((idx / total) * 100) if total > 0 else 100 + job_manager.update_job(job_id, message=f"Estimating {idx}/{total}", progress=progress) + + job_manager.complete_job(job_id, result={"total": total, "processed": processed}) + + except Exception as e: + logger.error(f"Estimate power job failed: {e}") + job_manager.fail_job(job_id, str(e)) + +def run_segment_matching_task(job_id: str, db_session_factory, **kwargs): + """Background task wrapper for segment matching""" + # Just call the job function directly, it handles everything + run_segment_matching_job(job_id, db_session_factory=db_session_factory, **kwargs) + diff --git a/FitnessSync/backend/src/utils/algorithms.py b/FitnessSync/backend/src/utils/algorithms.py new file mode 100644 index 0000000..7b05a7c --- /dev/null +++ b/FitnessSync/backend/src/utils/algorithms.py @@ -0,0 +1,70 @@ +from typing import List, Tuple +import math + +def lttb(data: List[Tuple[float, float]], threshold: int) -> List[Tuple[float, float]]: + """ + Largest-Triangle-Three-Buckets algorithm for downsampling time series data. + Preserves visual shape (peaks and valleys) better than simple decimation. + + Args: + data: List of (x, y) tuples. Must be sorted by x. + threshold: Target number of points. + + Returns: + Downsampled list of (x, y) tuples. + """ + if not data or len(data) <= threshold: + return data + + sampled = [] + sampled.append(data[0]) # Always keep the first point + + # Bucket size. Leave room for start and end data points + every = (len(data) - 2) / (threshold - 2) + + a = 0 + next_a = 0 + max_area_point = (0, 0) + + for i in range(threshold - 2): + # Calculate point average for next bucket (containing c) + avg_x = 0 + avg_y = 0 + avg_range_start = int(math.floor((i + 1) * every) + 1) + avg_range_end = int(math.floor((i + 2) * every) + 1) + avg_range_end = min(avg_range_end, len(data)) + avg_range_len = avg_range_end - avg_range_start + + for j in range(avg_range_start, avg_range_end): + avg_x += data[j][0] + avg_y += data[j][1] + + if avg_range_len > 0: + avg_x /= avg_range_len + avg_y /= avg_range_len + + # Get the range for this bucket + range_offs = int(math.floor((i + 0) * every) + 1) + range_to = int(math.floor((i + 1) * every) + 1) + + # Point a + point_a_x = data[a][0] + point_a_y = data[a][1] + + max_area = -1 + + for j in range(range_offs, range_to): + # Calculate triangle area over three buckets + area = math.fabs((point_a_x - avg_x) * (data[j][1] - point_a_y) - + (point_a_x - data[j][0]) * (avg_y - point_a_y)) * 0.5 + if area > max_area: + max_area = area + max_area_point = data[j] + next_a = j + + sampled.append(max_area_point) + a = next_a + + sampled.append(data[-1]) # Always keep the last point + + return sampled diff --git a/FitnessSync/backend/templates/activities.html b/FitnessSync/backend/templates/activities.html index 4b68c62..f7fac0a 100644 --- a/FitnessSync/backend/templates/activities.html +++ b/FitnessSync/backend/templates/activities.html @@ -45,6 +45,65 @@ + +

+ +
@@ -392,12 +451,26 @@ let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`; // If any filter is active, force query mode - if (typeFilter || bikeFilter) { + // If any filter is active, force query mode + const hasHr = document.getElementById('filter-has-hr').checked; + const hasPower = document.getElementById('filter-has-power').checked; + const hasCadence = document.getElementById('filter-has-cadence').checked; + const powerReal = document.getElementById('filter-power-real').checked; + const powerEst = document.getElementById('filter-power-est').checked; + + if (typeFilter || bikeFilter || hasHr || hasPower || hasCadence || powerReal || powerEst) { url = `/api/activities/query?`; const params = new URLSearchParams(); if (typeFilter) params.append('activity_type', typeFilter); if (bikeFilter) params.append('bike_setup_id', bikeFilter); + if (hasHr) params.append('has_hr', 'true'); + if (hasPower) params.append('has_power', 'true'); + if (hasCadence) params.append('has_cadence', 'true'); + + if (powerReal) params.append('is_estimated_power', 'false'); + if (powerEst) params.append('is_estimated_power', 'true'); + url += params.toString(); document.getElementById('prev-page-btn').disabled = true; diff --git a/FitnessSync/backend/templates/activity_view.html b/FitnessSync/backend/templates/activity_view.html index f726e68..17af5f0 100644 --- a/FitnessSync/backend/templates/activity_view.html +++ b/FitnessSync/backend/templates/activity_view.html @@ -65,6 +65,9 @@ +
+ Discovery + @@ -231,6 +234,19 @@ + +
+
+
Respiration
+
+
Avg: - + br/min
+
Max: - br/min +
+
+
+
+
@@ -249,11 +265,78 @@
-
- Activity Streams +
+ Activity Streams +
+
+ + +
+ +
-
- +
+
+
+

Cycling Workout Analysis

+

Toggle metrics to customize view

+
+
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+
+ + +
- -
How often this job should run.
+ +
How often this job should run. Set to 0 for Manual Only (Adhoc).
@@ -261,13 +261,7 @@
@@ -277,7 +271,8 @@
- + +
Set to 0 for Manual Only.
@@ -366,6 +361,7 @@ loadJobs(); if (typeof updateParamsHelp === 'function') updateParamsHelp(); // Init helper text loadDashboardData(); + populateJobTypes(); // Auto-refresh dashboard data every 3 seconds setInterval(loadDashboardData, 3000); @@ -851,7 +847,7 @@ const paramsStr = document.getElementById('create-job-params').value; if (!name) { alert("Name is required"); return; } - if (interval < 1) { alert("Interval must be > 0"); return; } + if (interval < 0) { alert("Interval must be >= 0"); return; } let params = {}; try { @@ -992,8 +988,8 @@ const enabled = document.getElementById('edit-job-enabled').checked; const paramsStr = document.getElementById('edit-job-params').value; - if (interval < 1) { - alert("Interval must be at least 1 minute"); + if (interval < 0) { + alert("Interval must be at least 0 minutes"); return; } @@ -1029,5 +1025,32 @@ alert("Error: " + e.message); } } + async function populateJobTypes() { + const select = document.getElementById('create-job-type'); + try { + const response = await fetch('/api/scheduling/available-types'); + if (!response.ok) throw new Error('Failed to fetch types'); + const types = await response.json(); + + select.innerHTML = ''; + types.forEach(type => { + const option = document.createElement('option'); + option.value = type; + // Simple label transformation: underscores to spaces, Title Case + option.text = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + select.appendChild(option); + }); + + // Trigger help update for first item + if (types.length > 0) { + select.selectedIndex = 0; + updateParamsHelp(); + } + + } catch (e) { + console.error("Error loading job types", e); + select.innerHTML = ''; + } + } {% endblock %} \ No newline at end of file diff --git a/FitnessSync/backend/templates/segments.html b/FitnessSync/backend/templates/segments.html index cd9d240..5ad1284 100644 --- a/FitnessSync/backend/templates/segments.html +++ b/FitnessSync/backend/templates/segments.html @@ -82,11 +82,14 @@ + + @@ -96,12 +99,45 @@
Rank Date Time Avg HR WattsMax Watts
+
+ + +
+ + + + {% endblock %} {% block scripts %} @@ -113,7 +149,7 @@ if (!confirm("This will rescan ALL activities for all segments. It may take a while. Continue?")) return; try { - const response = await fetch('/api/segments/scan', { method: 'POST' }); + const response = await fetch('/api/segments/scan?force=true', { method: 'POST' }); if (!response.ok) throw new Error("Scan failed"); const data = await response.json(); alert("Scan started! Background Job ID: " + data.job_id); @@ -181,6 +217,7 @@ let map = null; let elevationChart = null; + let comparisonChart = null; function viewSegment(seg) { const modal = new bootstrap.Modal(document.getElementById('viewSegmentModal')); @@ -312,14 +349,21 @@ const tr = document.createElement('tr'); tr.innerHTML = ` + + + ${rank} ${date} ${timeStr} ${effort.avg_hr || '-'} ${effort.avg_power || '-'} + ${effort.max_power || '-'} `; tbody.appendChild(tr); }); + updateActionButtons(); } catch (e) { console.error(e); @@ -327,6 +371,222 @@ } } + function toggleAllEfforts(source) { + document.querySelectorAll('.effort-checkbox').forEach(cb => { + cb.checked = source.checked; + }); + updateActionButtons(); + } + + function updateActionButtons() { + const selected = document.querySelectorAll('.effort-checkbox:checked').length; + document.getElementById('btn-compare').disabled = selected < 2; + document.getElementById('btn-export').disabled = selected < 1; + } + + async function compareSelectedEfforts() { + const checkboxes = document.querySelectorAll('.effort-checkbox:checked'); + const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (ids.length < 2) return; + + try { + const res = await fetch('/api/segments/efforts/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids) + }); + + if (!res.ok) throw new Error("Comparison failed"); + const data = await res.json(); + + renderComparisonTable(data); + new bootstrap.Modal(document.getElementById('compareModal')).show(); + + } catch (e) { + alert("Error: " + e.message); + } + } + + function renderComparisonTable(data) { + const table = document.getElementById('comparison-table'); + const efforts = data.efforts; + const winners = data.winners; + + // Define rows to display + const rows = [ + { key: 'date', label: 'Date', format: v => new Date(v).toLocaleDateString() }, + { key: 'elapsed_time', label: 'Time', format: v => new Date(v * 1000).toISOString().substr(11, 8) }, + { key: 'avg_power', label: 'Avg Power (W)' }, + { key: 'max_power', label: 'Max Power (W)' }, + { key: 'avg_hr', label: 'Avg HR (bpm)' }, + { key: 'watts_per_kg', label: 'Watts/kg' }, + { key: 'avg_speed', label: 'Speed (m/s)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'avg_cadence', label: 'Cadence' }, + { key: 'avg_respiration_rate', label: 'Respiration (br/min)', format: v => v ? v.toFixed(1) : '-' }, + { key: 'avg_temperature', label: 'Temp (C)', format: v => v ? v.toFixed(1) : '-' }, + { key: 'body_weight', label: 'Body Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'bike_weight', label: 'Bike Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'total_weight', label: 'Total Weight (kg)', format: v => v ? v.toFixed(2) : '-' }, + { key: 'bike_name', label: 'Bike' } + ]; + + let html = 'Metric'; + efforts.forEach(e => { + html += `${e.activity_name}`; + }); + html += ''; + + rows.forEach(row => { + html += `${row.label}`; + efforts.forEach(e => { + let val = e[row.key]; + let displayVal = val; + if (val === null || val === undefined) displayVal = '-'; + else if (row.format) displayVal = row.format(val); + + // Highlight winner + let bgClass = ''; + if (winners[row.key] === e.effort_id) { + bgClass = 'table-success fw-bold'; + } + + html += `${displayVal}`; + }); + html += ''; + }); + + html += ''; + table.innerHTML = html; + } + + async function exportSelectedEfforts() { + const checkboxes = document.querySelectorAll('.effort-checkbox:checked'); + const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); + + if (ids.length === 0) return; + + try { + const res = await fetch('/api/segments/efforts/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids) + }); + + if (!res.ok) throw new Error("Export failed"); + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = "efforts_analysis.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + + } catch (e) { + alert("Error: " + e.message); + } + } + + function renderComparisonChart(efforts) { + if (comparisonChart) { + comparisonChart.destroy(); + comparisonChart = null; + } + + const ctx = document.getElementById('comparisonChart').getContext('2d'); + + // Prepare datasets + // We will plot formatted 'Watts/kg' and 'Avg Speed' side by side? + // Or normalized? + // Let's plot Power, HR, and Watts/kg as bars. + // Since scales are different, we might need multiple axes or just plot one metric? + // User asked for "charts" (plural?). + // Getting simple: Grouped Bar Chart for Power and HR (scale 0-300ish). + // Watts/kg is small (0-10). + + // Let's use two y-axes: Left for Power/HR, Right for W/kg + + const labels = efforts.map(e => e.activity_name); + + comparisonChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Avg Power (W)', + data: efforts.map(e => e.avg_power), + backgroundColor: 'rgba(255, 99, 132, 0.6)', + yAxisID: 'y' + }, + { + label: 'Avg HR (bpm)', + data: efforts.map(e => e.avg_hr), + backgroundColor: 'rgba(54, 162, 235, 0.6)', + yAxisID: 'y' + }, + { + label: 'Watts/kg', + type: 'line', // Line overlay for W / kg + data: efforts.map(e => e.watts_per_kg), + borderColor: 'rgba(75, 192, 192, 1)', + borderWidth: 2, + yAxisID: 'y1' + }, + { + label: 'Avg Speed (m/s)', + data: efforts.map(e => e.avg_speed), + backgroundColor: 'rgba(153, 102, 255, 0.6)', + yAxisID: 'y2', + hidden: false + }, + { + label: 'Avg Cadence (rpm)', + data: efforts.map(e => e.avg_cadence), + backgroundColor: 'rgba(255, 159, 64, 0.6)', + yAxisID: 'y', + hidden: true + }, + { + label: 'Avg Respiration (br/min)', + data: efforts.map(e => e.avg_respiration_rate), + backgroundColor: 'rgba(201, 203, 207, 0.6)', + yAxisID: 'y', + hidden: true + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { display: true, text: 'Power (W) / HR (bpm)' } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: 'Watts/kg' } + }, + y2: { + type: 'linear', + display: true, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: 'Speed' } + } + } + } + }); + } + document.addEventListener('DOMContentLoaded', loadSegments); {% endblock %} \ No newline at end of file diff --git a/FitnessSync/backend/test_segment_optimization.py b/FitnessSync/backend/test_segment_optimization.py new file mode 100644 index 0000000..9523846 --- /dev/null +++ b/FitnessSync/backend/test_segment_optimization.py @@ -0,0 +1,87 @@ + +import sys +import os +import logging +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql import func +import datetime + +# Setup environment +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from src.models.activity import Activity +from src.models.segment import Segment +from src.services.postgresql_manager import PostgreSQLManager +from src.utils.config import config +from src.services.job_manager import job_manager + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_optimization(): + db_manager = PostgreSQLManager(config.DATABASE_URL) + with db_manager.get_db_session() as db: + # 1. Reset + activity = db.query(Activity).filter(Activity.id == 256).first() + if not activity: + print("Activity 256 not found") + return + + print("Resetting last_segment_scan_timestamp...") + activity.last_segment_scan_timestamp = None + db.commit() + + # 2. Get Max Segment Date + max_seg_date = db.query(func.max(Segment.created_at)).scalar() + print(f"Max Segment Date: {max_seg_date}") + + # 3. Simulate Logic Check (Pass 1) + last_scan = activity.last_segment_scan_timestamp + if last_scan and max_seg_date and last_scan >= max_seg_date: + print("PASS 1: Optimization INCORRECTLY skipped!") + else: + print("PASS 1: Optimization correctly signaled to scan.") + # Simulate scan completion + activity.last_segment_scan_timestamp = datetime.datetime.now(datetime.timezone.utc) + db.commit() + + # 4. Refresh & Check Pass 2 + db.refresh(activity) + last_scan = activity.last_segment_scan_timestamp + print(f"Activity Last Scan: {last_scan}") + + if last_scan and max_seg_date and last_scan >= max_seg_date: + print("PASS 2: Optimization CORRECTLY skipped.") + else: + print(f"PASS 2: Optimization FAILED to skip. last_scan={last_scan} max_seg={max_seg_date}") + + # 5. Add New Segment + print("Creating new segment...") + new_seg = Segment( + name="Future Segment", + points="[]", + bounds="[]", + distance=100, + activity_type="cycling" + # created_at defaults to now + ) + db.add(new_seg) + db.commit() # created_at set + + # Refresh max date logic + max_seg_date_new = db.query(func.max(Segment.created_at)).scalar() + print(f"New Max Segment Date: {max_seg_date_new}") + + if last_scan and max_seg_date_new and last_scan >= max_seg_date_new: + print("PASS 3: Optimization FAILED (Generated False Positive Skip).") + else: + print("PASS 3: Optimization CORRECTLY signaled re-scan (Partial).") + + # Cleanup + db.delete(new_seg) + db.commit() + +if __name__ == "__main__": + test_optimization() diff --git a/FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc b/FitnessSync/backend/tests/__pycache__/test_analysis.cpython-311-pytest-7.4.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f6865d0764adafbbba9cc59780ae1694cef52a7 GIT binary patch literal 9235 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 Z 100: + print("No segments matched. Creating a dummy segment to verify logic...") + # Create a simple segment from the middle of the activity + mid = len(points) // 2 + seg_points = points[mid:mid+50] # 50 points + + # Bounds + from src.utils.geo import calculate_bounds + import json + bounds = calculate_bounds(seg_points) + + dummy_segment = Segment( + name="Temp Verify Segment", + activity_type=activity.activity_type, + distance=1000.0, # Approximate + points=json.dumps(seg_points), + bounds=json.dumps(bounds) + ) + session.add(dummy_segment) + session.commit() + print(f"Created dummy segment {dummy_segment.id}") + + # Retry Match + efforts = matcher.match_activity(activity, points) + print(f"Retried Match: {len(efforts)} segments.") + + # 5. Verify Power + for effort in efforts: + print(f"Effort ID: {effort.id}, Segment ID: {effort.segment_id}") + print(f" Avg Power: {effort.avg_power}") + print(f" Avg HR: {effort.avg_hr}") + print(f" Elapsed: {effort.elapsed_time}s") + + if effort.avg_power and effort.avg_power > 0: + print("SUCCESS: Segment Effort has power!") + else: + print("FAILURE: Segment Effort missing power.") + +if __name__ == "__main__": + verify_segment_power() diff --git a/FitnessSync/docs/specs/fitbit-integration.md b/FitnessSync/docs/specs/fitbit-integration.md new file mode 100644 index 0000000..ec9dd69 --- /dev/null +++ b/FitnessSync/docs/specs/fitbit-integration.md @@ -0,0 +1,114 @@ +# Fitbit Integration Specification + +**Status**: Implemented +**Version**: 1.0.0 +**Last Updated**: 2026-01-12 + +## 1. Overview +This specification documents the current implementation of the Fitbit integration within the FitTrack2 ecosystem. The primary goal is to authenticate with the Fitbit API using OAuth 2.0 and synchronize health metrics—specifically body weight and BMI—into the local database for analysis and visualization. + +## 2. Authentication (OAuth 2.0) + +### 2.1 Credentials Management +- **Storage**: Client ID and Client Secret are stored in the `configuration` table. +- **Tokens**: Access and Refresh tokens are stored in the `api_tokens` table with `token_type='fitbit'`. + +### 2.2 Authorization Flow +1. **Initiation**: + - Endpoint: `POST /setup/fitbit` + - Input: `client_id`, `client_secret`, `redirect_uri` + - Process: Generates an Authorization URL using the `fitbit` Python library. + - Scopes Requested: `['weight', 'nutrition', 'activity', 'sleep', 'heartrate', 'profile']` +2. **Callback**: + - Endpoint: `POST /setup/fitbit/callback` + - Input: `code` (Authorization Code) + - Process: Exchanges code for Access and Refresh tokens. + - Output: Saves tokens to `api_tokens` table. + +### 2.3 Token Refresh +- **Mechanism**: The `FitbitClient` handles token expiration. +- **Note**: Current implementation of `refresh_access_token` in `FitbitClient` contains mock logic and requires finalization for production use. + +## 3. Data Synchronization + +### 3.1 Supported Metrics +Currently, only **Weight** and **BMI** are synchronized. + +### 3.2 Sync Strategy +- **On-Demand**: Triggered via API or UI. +- **Scopes**: + - `30d`: Syncs the last 30 days. + - `all`: Syncs all history starting from 2015-01-01. +- **Chunking**: Requests are chunked into 30-day intervals to respect API limits. +- **Rate Limiting**: Custom handling for `HTTP 429 Too Many Requests`. The system parses `Retry-After` headers and sleeps automatically before retrying a chunk. + +### 3.3 Data Storage +Data is stored in the `weight_records` table. + +| Column | Type | Description | +| :--- | :--- | :--- | +| `id` | Integer | Primary Key | +| `fitbit_id` | String | Unique Log ID from Fitbit | +| `weight` | Float | Weight in Kg | +| `bmi` | Float | Calculated BMI | +| `unit` | String | Unit (default 'kg') | +| `date` | DateTime | Timestamp of measurement | +| `sync_status` | String | Status for downstream sync (e.g. 'unsynced') | + +### 3.4 Logic (Deduplication) +- Incoming records are matched against existing records by `fitbit_id`. +- **Updates**: If `weight` differs by > 0.01 or `bmi` was missing, the record is updated. +- **Inserts**: New records are created if no match is found. + +## 4. API Endpoints + +### 4.1 Configuration & Auth +- `POST /setup/fitbit`: Save credentials and get Auth URL. +- `POST /setup/fitbit/callback`: Exchange code for token. +- `POST /setup/fitbit/test-token`: Verify current token validity and fetch user profile. + +### 4.2 Synchronization +- `POST /api/sync/fitbit/weight`: Trigger sync. + - Body: `{ "scope": "30d" | "all" }` +- `GET /api/jobs/{job_id}`: Check sync status (if run as background job). + +### 4.3 Data Access +- `GET /api/metrics/query`: + - Params: `source=fitbit`, `metric_type=weight`, `start_date`, `end_date`. + - Returns: serialized `WeightRecord` objects. +- `POST /api/sync/compare-weight`: Compares Fitbit weight dates vs Garmin weight dates to find gaps. + +## 5. Frontend UI +The UI is split between configuration (setup) and visualization (health dashboard). + +### 5.1 Authentication UI (`backend/templates/setup.html`) +The setup page handles the OAuth 2.0 flow: +1. **Credentials Form**: Inputs for `Client ID`, `Client Secret`, and `Redirect URI` (defaulting to `http://localhost:8000/fitbit_callback`). +2. **Actions**: + - `Test Fitbit Credentials`: Validates inputs and generates the Authorization URL. + - `Save Fitbit Credentials`: Persists credentials to the database. + - `Test Current Fitbit Token`: Checks if the stored token is valid. +3. **OAuth Flow**: + - After validation, a link "Authorize with Fitbit" is displayed. + - User clicks link, authorizes on Fitbit, and is redirected to the `fitbit_callback` URL (localhost). + - **Manual Step**: User copys the full URL (including code) and pastes it into the "Paste full callback URL" input. + - `Complete OAuth Flow` button sends the code to the backend. + +### 5.2 Health Dashboard (`backend/templates/fitbit_health.html`) +Located at `backend/templates/fitbit_health.html`. + +#### Features +- **Sync Controls**: Buttons to trigger "Sync Recent (30d)" and "Sync All". +- **Comparison**: "Compare vs Garmin" modal showing counts and missing dates. +- **Data Table**: Displays Date, Weight, BMI, and Source. +- **Filtering**: Date range pickers (Start/End) and quick toggles (30d, 1y, All). + +## 6. Dependencies +- **Libraries**: + - `fitbit` (Python client for interaction) + - `sqlalchemy` (ORM) + - `pydantic` (Data validation) + +## 7. Future Considerations +- **Token Refresh**: Implement real token refresh logic in `FitbitClient`. +- **Additional Metrics**: Expand sync to cover Heart Rate, Sleep, and Activities using the already authorized scopes.