From 9dcbaab6342af63cb6b4b664306a74f26e88aa20 Mon Sep 17 00:00:00 2001 From: Carmen Alvarez Date: Fri, 26 Jul 2024 22:31:19 +0200 Subject: [PATCH] Add distance in km to activity records. --- ...099f4_add_fitbit_activities_distance_km.py | 33 ++++++++++++++ slackhealthbot/data/database/models.py | 1 + .../sqlalchemyfitbitrepository.py | 4 ++ slackhealthbot/domain/models/activity.py | 2 + .../usecases/slack/usecase_post_activity.py | 44 ++++++++++++++++++- .../remoteservices/api/fitbit/activityapi.py | 2 + .../repositories/webapifitbitrepository.py | 5 +++ .../repositories/test_fitbitrepository.py | 11 +++++ .../slack/test_usecase_post_activity.py | 11 +++++ tests/test_factories.py | 1 + tests/testsupport/factories/factories.py | 1 + .../testsupport/fixtures/fitbit_scenarios.py | 1 + 12 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/2024_07_26_2214-22857e6099f4_add_fitbit_activities_distance_km.py diff --git a/alembic/versions/2024_07_26_2214-22857e6099f4_add_fitbit_activities_distance_km.py b/alembic/versions/2024_07_26_2214-22857e6099f4_add_fitbit_activities_distance_km.py new file mode 100644 index 00000000..db9ca067 --- /dev/null +++ b/alembic/versions/2024_07_26_2214-22857e6099f4_add_fitbit_activities_distance_km.py @@ -0,0 +1,33 @@ +"""add_fitbit_activities_distance_km + +Revision ID: 22857e6099f4 +Revises: 77dca2f35afa +Create Date: 2024-07-26 22:14:38.959111 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "22857e6099f4" +down_revision = "77dca2f35afa" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("fitbit_activities", schema=None) as batch_op: + batch_op.add_column(sa.Column("distance_km", sa.Float(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("fitbit_activities", schema=None) as batch_op: + batch_op.drop_column("distance_km") + + # ### end Alembic commands ### diff --git a/slackhealthbot/data/database/models.py b/slackhealthbot/data/database/models.py index c238ad36..1eef5109 100644 --- a/slackhealthbot/data/database/models.py +++ b/slackhealthbot/data/database/models.py @@ -65,6 +65,7 @@ class FitbitActivity(TimestampMixin, Base): type_id: Mapped[int] = mapped_column() total_minutes: Mapped[int] = mapped_column() calories: Mapped[int] = mapped_column() + distance_km: Mapped[Optional[float]] = mapped_column() fat_burn_minutes: Mapped[Optional[int]] = mapped_column() cardio_minutes: Mapped[Optional[int]] = mapped_column() peak_minutes: Mapped[Optional[int]] = mapped_column() diff --git a/slackhealthbot/data/repositories/sqlalchemyfitbitrepository.py b/slackhealthbot/data/repositories/sqlalchemyfitbitrepository.py index 69c04fc8..464c5b0b 100644 --- a/slackhealthbot/data/repositories/sqlalchemyfitbitrepository.py +++ b/slackhealthbot/data/repositories/sqlalchemyfitbitrepository.py @@ -207,6 +207,7 @@ async def create_activity_for_user( type_id=activity.type_id, total_minutes=activity.total_minutes, calories=activity.calories, + distance_km=activity.distance_km, **{f"{x.zone}_minutes": x.minutes for x in activity.zone_minutes}, fitbit_user_id=user.id, ) @@ -276,6 +277,7 @@ async def get_top_activity_stats_by_user_and_activity_type( columns = [ models.FitbitActivity.calories, + models.FitbitActivity.distance_km, models.FitbitActivity.total_minutes, models.FitbitActivity.fat_burn_minutes, models.FitbitActivity.cardio_minutes, @@ -301,6 +303,7 @@ async def get_top_activity_stats_by_user_and_activity_type( return TopActivityStats( top_calories=row["top_calories"], + top_distance_km=row["top_distance_km"], top_total_minutes=row["top_total_minutes"], top_zone_minutes=[ ActivityZoneMinutes( @@ -320,6 +323,7 @@ def _db_activity_to_domain_activity( log_id=db_activity.log_id, type_id=db_activity.type_id, calories=db_activity.calories, + distance_km=db_activity.distance_km, total_minutes=db_activity.total_minutes, zone_minutes=[ ActivityZoneMinutes( diff --git a/slackhealthbot/domain/models/activity.py b/slackhealthbot/domain/models/activity.py index 68c42909..b109d008 100644 --- a/slackhealthbot/domain/models/activity.py +++ b/slackhealthbot/domain/models/activity.py @@ -21,12 +21,14 @@ class ActivityData: type_id: int total_minutes: int calories: int + distance_km: float | None zone_minutes: list[ActivityZoneMinutes] @dataclasses.dataclass class TopActivityStats: top_calories: int | None + top_distance_km: float | None top_total_minutes: int | None top_zone_minutes: list[ActivityZoneMinutes] diff --git a/slackhealthbot/domain/usecases/slack/usecase_post_activity.py b/slackhealthbot/domain/usecases/slack/usecase_post_activity.py index 5485d3f5..b5202288 100644 --- a/slackhealthbot/domain/usecases/slack/usecase_post_activity.py +++ b/slackhealthbot/domain/usecases/slack/usecase_post_activity.py @@ -34,6 +34,19 @@ def create_message( calories_icon = get_activity_calories_change_icon( activity.calories - activity_history.latest_activity_data.calories, ) + distance_km_icon = ( + get_activity_distance_km_change_icon( + ( + activity.distance_km + - activity_history.latest_activity_data.distance_km + ) + * 100 + / activity.distance_km, + ) + if activity.distance_km + else None + ) + for zone_minutes in activity.zone_minutes: last_zone_minutes = next( ( @@ -48,7 +61,7 @@ def create_message( ) else: - duration_icon = calories_icon = "" + duration_icon = calories_icon = distance_km_icon = "" duration_record_text = get_ranking_text( activity.total_minutes, activity_history.all_time_top_activity_data.top_total_minutes, @@ -61,6 +74,17 @@ def create_message( activity_history.recent_top_activity_data.top_calories, record_history_days=record_history_days, ) + distance_km_record_text = ( + get_ranking_text( + activity.distance_km, + activity_history.all_time_top_activity_data.top_distance_km, + activity_history.recent_top_activity_data.top_distance_km, + record_history_days=record_history_days, + ) + if activity.distance_km + else None + ) + for zone_minutes in activity.zone_minutes: all_time_top_value = next( ( @@ -89,6 +113,8 @@ def create_message( • Duration: {activity.total_minutes} minutes {duration_icon} {duration_record_text} • Calories: {activity.calories} {calories_icon} {calories_record_text} """ + if activity.distance_km: + message += f" • Distance: {activity.distance_km}km {distance_km_icon} {distance_km_record_text}" message += "\n".join( [ f" • {format_activity_zone(zone_minutes.zone)}" @@ -137,6 +163,22 @@ def get_activity_calories_change_icon(calories_change: int) -> str: return "➡️" +DISTANCE_CHANGE_PCT_SMALL = 15 +DISTANCE_CHANGE_PCT_LARGE = 25 + + +def get_activity_distance_km_change_icon(distance_km_change_pct: int) -> str: + if distance_km_change_pct > DISTANCE_CHANGE_PCT_LARGE: + return "⬆️" + if distance_km_change_pct > DISTANCE_CHANGE_PCT_SMALL: + return "↗️" + if distance_km_change_pct < -DISTANCE_CHANGE_PCT_LARGE: + return "⬇️" + if distance_km_change_pct < -DISTANCE_CHANGE_PCT_LARGE: + return "↘️" + return "➡️" + + def get_ranking_text( value: int, all_time_top_value: int, diff --git a/slackhealthbot/remoteservices/api/fitbit/activityapi.py b/slackhealthbot/remoteservices/api/fitbit/activityapi.py index b2dd7739..57d10fe9 100644 --- a/slackhealthbot/remoteservices/api/fitbit/activityapi.py +++ b/slackhealthbot/remoteservices/api/fitbit/activityapi.py @@ -28,6 +28,8 @@ class FitbitActivity(BaseModel): activityTypeId: int calories: int duration: int + distance: float | None = None + distanceUnit: str | None = None class FitbitActivities(BaseModel): diff --git a/slackhealthbot/remoteservices/repositories/webapifitbitrepository.py b/slackhealthbot/remoteservices/repositories/webapifitbitrepository.py index 949136aa..aadd0c0c 100644 --- a/slackhealthbot/remoteservices/repositories/webapifitbitrepository.py +++ b/slackhealthbot/remoteservices/repositories/webapifitbitrepository.py @@ -96,6 +96,11 @@ def remote_service_activity_to_domain_activity( log_id=fitbit_activity.logId, type_id=fitbit_activity.activityTypeId, calories=fitbit_activity.calories, + distance_km=( + fitbit_activity.distance + if fitbit_activity.distanceUnit == "Kilometer" + else None + ), total_minutes=fitbit_activity.duration // 60000, zone_minutes=[ ActivityZoneMinutes(zone=ActivityZone[x.type.upper()], minutes=x.minutes) diff --git a/tests/data/repositories/test_fitbitrepository.py b/tests/data/repositories/test_fitbitrepository.py index dc515505..6e9f5320 100644 --- a/tests/data/repositories/test_fitbitrepository.py +++ b/tests/data/repositories/test_fitbitrepository.py @@ -37,6 +37,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=activity_type, calories=600, + distance_km=None, # TODO test this total_minutes=18, fat_burn_minutes=17, cardio_minutes=16, @@ -51,6 +52,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=activity_type, calories=599, + distance_km=None, # TODO test this total_minutes=18, fat_burn_minutes=17, cardio_minutes=16, @@ -65,6 +67,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=activity_type, calories=333, + distance_km=None, # TODO test this total_minutes=30, fat_burn_minutes=29, cardio_minutes=28, @@ -78,6 +81,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=activity_type, calories=333, + distance_km=None, # TODO test this total_minutes=29, fat_burn_minutes=28, cardio_minutes=27, @@ -90,6 +94,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=activity_type, calories=400, + distance_km=None, # TODO test this total_minutes=20, fat_burn_minutes=19, cardio_minutes=18, @@ -102,6 +107,7 @@ async def test_top_activities( fitbit_user_id=other_user.fitbit.id, type_id=activity_type, calories=800, + distance_km=None, # TODO test this total_minutes=69, fat_burn_minutes=68, cardio_minutes=67, @@ -114,6 +120,7 @@ async def test_top_activities( fitbit_user_id=user.fitbit.id, type_id=999, calories=900, + distance_km=None, # TODO test this total_minutes=98, fat_burn_minutes=97, cardio_minutes=96, @@ -129,6 +136,7 @@ async def test_top_activities( ) assert all_time_top_activity_stats == TopActivityStats( top_calories=all_time_top_calories_activity.calories, + top_distance_km=None, # TODO test this top_total_minutes=all_time_top_minutes_activity.total_minutes, top_zone_minutes=[ ActivityZoneMinutes( @@ -155,6 +163,7 @@ async def test_top_activities( ) assert recent_top_activity_stats == TopActivityStats( top_calories=recent_top_calories_activity.calories, + top_distance_km=None, # TODO test this top_total_minutes=recent_top_minutes_activity.total_minutes, top_zone_minutes=[ ActivityZoneMinutes( @@ -191,6 +200,7 @@ async def test_top_activities_no_history( ) assert all_time_top_activity_stats == TopActivityStats( top_calories=None, + top_distance_km=None, top_total_minutes=None, top_zone_minutes=[], ) @@ -204,6 +214,7 @@ async def test_top_activities_no_history( ) assert recent_top_activity_stats == TopActivityStats( top_calories=None, + top_distance_km=None, top_total_minutes=None, top_zone_minutes=[], ) diff --git a/tests/domain/usecases/slack/test_usecase_post_activity.py b/tests/domain/usecases/slack/test_usecase_post_activity.py index 134f1322..d7121666 100644 --- a/tests/domain/usecases/slack/test_usecase_post_activity.py +++ b/tests/domain/usecases/slack/test_usecase_post_activity.py @@ -59,6 +59,11 @@ def test_get_activity_calories_change_icon( assert actual_output == expected_output +def test_get_activity_distance_km_change_icon(): + # TODO + pass + + @pytest.mark.parametrize( [ "input_value", @@ -102,6 +107,7 @@ class CreateMessageScenario: type_id=123, total_minutes=120, calories=315, + distance_km=None, # TODO test this zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, @@ -123,6 +129,7 @@ class CreateMessageScenario: type_id=123, total_minutes=90, calories=175, + distance_km=None, # TODO test this zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, @@ -144,6 +151,7 @@ class CreateMessageScenario: type_id=123, total_minutes=1, calories=1, + distance_km=None, # TODO test this zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, @@ -173,6 +181,7 @@ def test_create_message(scenario: CreateMessageScenario): type_id=123, total_minutes=15, calories=150, + distance_km=None, # TODO test this zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, @@ -187,6 +196,7 @@ def test_create_message(scenario: CreateMessageScenario): all_time_top_activity_data=TopActivityStats( top_total_minutes=100, top_calories=215, + top_distance_km=None, # TODO test this top_zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, @@ -201,6 +211,7 @@ def test_create_message(scenario: CreateMessageScenario): recent_top_activity_data=TopActivityStats( top_total_minutes=90, top_calories=175, + top_distance_km=None, # TODO test this top_zone_minutes=[ ActivityZoneMinutes( zone=ActivityZone.CARDIO, diff --git a/tests/test_factories.py b/tests/test_factories.py index 79cb0b28..3cbca4f5 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -125,6 +125,7 @@ async def test_fitbit_activity_factory( assert repo_activity.type_id == fitbit_activity.type_id assert repo_activity.total_minutes == fitbit_activity.total_minutes assert repo_activity.calories == fitbit_activity.calories + assert repo_activity.distance_km == fitbit_activity.distance_km assert repo_activity.zone_minutes == [ ActivityZoneMinutes( zone=ActivityZone.PEAK, diff --git a/tests/testsupport/factories/factories.py b/tests/testsupport/factories/factories.py index 2800459d..88014739 100644 --- a/tests/testsupport/factories/factories.py +++ b/tests/testsupport/factories/factories.py @@ -29,6 +29,7 @@ class Meta: type_id = Faker("pyint") total_minutes = Faker("pyint") calories = Faker("pyint") + distance_km = Faker("pyfloat") cardio_minutes = Faker("pyint") fat_burn_minutes = Faker("pyint") peak_minutes = Faker("pyint") diff --git a/tests/testsupport/fixtures/fitbit_scenarios.py b/tests/testsupport/fixtures/fitbit_scenarios.py index 541139a1..7836dd40 100644 --- a/tests/testsupport/fixtures/fitbit_scenarios.py +++ b/tests/testsupport/fixtures/fitbit_scenarios.py @@ -408,6 +408,7 @@ def is_new_log_expected(self) -> bool: + "Fat burn.*12.*⬆️ New all-time record.*Cardio.*9.*⬇️ New record.*Out of range.*10.*↗️.*Peak.*11.*⬆️ New " "all-time record", ), + # TODO activity with kilometers "New unrecognized activity": FitbitActivityScenario( input_initial_activity_data={ "log_id": 1234,