diff --git a/backend/app/activities/activity_streams/constants.py b/backend/app/activities/activity_streams/constants.py new file mode 100644 index 000000000..3d8dd9058 --- /dev/null +++ b/backend/app/activities/activity_streams/constants.py @@ -0,0 +1,8 @@ +# Stream type constants for activity streams +STREAM_TYPE_HR = 1 +STREAM_TYPE_POWER = 2 +STREAM_TYPE_CADENCE = 3 +STREAM_TYPE_ELEVATION = 4 +STREAM_TYPE_SPEED = 5 +STREAM_TYPE_PACE = 6 +STREAM_TYPE_MAP = 7 \ No newline at end of file diff --git a/backend/app/activities/activity_streams/crud.py b/backend/app/activities/activity_streams/crud.py index ab56108c7..6a9afce53 100644 --- a/backend/app/activities/activity_streams/crud.py +++ b/backend/app/activities/activity_streams/crud.py @@ -1,6 +1,9 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session +import numpy as np +import datetime +import activities.activity_streams.constants as activity_streams_constants import activities.activity_streams.schema as activity_streams_schema import activities.activity_streams.models as activity_streams_models @@ -10,14 +13,16 @@ import activities.activity.models as activities_models import server_settings.crud as server_settings_crud +import users.user.crud as users_crud + import core.logger as core_logger -def get_activity_streams(activity_id: int, token_user_id: int, db: Session): +def get_activity_streams( + activity_id: int, token_user_id: int, db: Session +) -> list[activity_streams_schema.ActivityStreams] | None: try: - activity = activity_crud.get_activity_by_id( - activity_id, db - ) + activity = activity_crud.get_activity_by_id(activity_id, db) if not activity: # If the activity does not exist, return None @@ -42,20 +47,52 @@ def get_activity_streams(activity_id: int, token_user_id: int, db: Session): if not user_is_owner: activity_streams = [ - stream for stream in activity_streams + stream + for stream in activity_streams if not ( - (activity.hide_hr and stream.stream_type == 1) or - (activity.hide_power and stream.stream_type == 2) or - (activity.hide_cadence and stream.stream_type == 3) or - (activity.hide_elevation and stream.stream_type == 4) or - (activity.hide_speed and stream.stream_type == 5) or - (activity.hide_pace and stream.stream_type == 6) or - (activity.hide_map and stream.stream_type == 7) + ( + activity.hide_hr + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_HR + ) + or ( + activity.hide_power + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_POWER + ) + or ( + activity.hide_cadence + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_CADENCE + ) + or ( + activity.hide_elevation + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_ELEVATION + ) + or ( + activity.hide_speed + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_SPEED + ) + or ( + activity.hide_pace + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_PACE + ) + or ( + activity.hide_map + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_MAP + ) ) ] # Return the activity streams - return activity_streams + return [ + transform_activity_streams(stream, activity, db) + for stream in activity_streams + ] except Exception as err: # Log the exception core_logger.print_to_log( @@ -76,10 +113,8 @@ def get_public_activity_streams(activity_id: int, db: Session): # Return None if public sharable links are disabled if not server_settings or not server_settings.public_shareable_links: return None - - activity = activity_crud.get_activity_by_id_if_is_public( - activity_id, db - ) + + activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db) if not activity: # If the activity does not exist, return None @@ -104,22 +139,52 @@ def get_public_activity_streams(activity_id: int, db: Session): # Check if there are activity streams, if not return None if not activity_streams: return None - + activity_streams = [ - stream for stream in activity_streams + stream + for stream in activity_streams if not ( - (activity.hide_hr and stream.stream_type == 1) or - (activity.hide_power and stream.stream_type == 2) or - (activity.hide_cadence and stream.stream_type == 3) or - (activity.hide_elevation and stream.stream_type == 4) or - (activity.hide_speed and stream.stream_type == 5) or - (activity.hide_pace and stream.stream_type == 6) or - (activity.hide_map and stream.stream_type == 7) + ( + activity.hide_hr + and stream.stream_type == activity_streams_constants.STREAM_TYPE_HR + ) + or ( + activity.hide_power + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_POWER + ) + or ( + activity.hide_cadence + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_CADENCE + ) + or ( + activity.hide_elevation + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_ELEVATION + ) + or ( + activity.hide_speed + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_SPEED + ) + or ( + activity.hide_pace + and stream.stream_type + == activity_streams_constants.STREAM_TYPE_PACE + ) + or ( + activity.hide_map + and stream.stream_type == activity_streams_constants.STREAM_TYPE_MAP + ) ) ] # Return the activity streams - return activity_streams + return [ + transform_activity_streams(stream, activity, db) + for stream in activity_streams + ] except Exception as err: # Log the exception core_logger.print_to_log( @@ -132,16 +197,16 @@ def get_public_activity_streams(activity_id: int, db: Session): ) from err -def get_activity_stream_by_type(activity_id: int, stream_type: int, token_user_id: int, db: Session): +def get_activity_stream_by_type( + activity_id: int, stream_type: int, token_user_id: int, db: Session +): try: - activity = activity_crud.get_activity_by_id( - activity_id, db - ) + activity = activity_crud.get_activity_by_id(activity_id, db) if not activity: # If the activity does not exist, return None return None - + # Get the activity stream from the database activity_stream = ( db.query(activity_streams_models.ActivityStreams) @@ -155,29 +220,57 @@ def get_activity_stream_by_type(activity_id: int, stream_type: int, token_user_i # Check if there are activity stream if not return None if not activity_stream: return None - + user_is_owner = True if token_user_id != activity.user_id: user_is_owner = False if not user_is_owner: - if activity.hide_hr and activity_stream.stream_type == 1: + if ( + activity.hide_hr + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_HR + ): return None - if activity.hide_power and activity_stream.stream_type == 2: + if ( + activity.hide_power + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_POWER + ): return None - if activity.hide_cadence and activity_stream.stream_type == 3: + if ( + activity.hide_cadence + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_CADENCE + ): return None - if activity.hide_elevation and activity_stream.stream_type == 4: + if ( + activity.hide_elevation + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_ELEVATION + ): return None - if activity.hide_speed and activity_stream.stream_type == 5: + if ( + activity.hide_speed + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_SPEED + ): return None - if activity.hide_pace and activity_stream.stream_type == 6: + if ( + activity.hide_pace + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_PACE + ): return None - if activity.hide_map and activity_stream.stream_type == 7: + if ( + activity.hide_map + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_MAP + ): return None # Return the activity stream - return activity_stream + return transform_activity_streams(activity_stream, activity, db) except Exception as err: # Log the exception core_logger.print_to_log( @@ -190,6 +283,99 @@ def get_activity_stream_by_type(activity_id: int, stream_type: int, token_user_i ) from err +def transform_activity_streams(activity_stream, activity, db): + """ + Transforms an activity stream based on its stream type. + If the stream type of the given activity_stream is heart rate (HR), this function delegates + the transformation to the `transform_activity_streams_hr` function. Otherwise, it returns + the activity_stream unchanged. + Args: + activity_stream: The activity stream object to be transformed. + activity: The activity object associated with the stream. + db: The database session or connection object. + Returns: + The transformed activity stream if the stream type is HR, otherwise the original activity_stream. + """ + if activity_stream.stream_type == activity_streams_constants.STREAM_TYPE_HR: + return transform_activity_streams_hr(activity_stream, activity, db) + + return activity_stream + + +def transform_activity_streams_hr(activity_stream, activity, db): + """ + Transforms an activity stream by calculating the percentage of time spent in each heart rate zone based on user details. + Args: + activity_stream: The activity stream object containing waypoints with heart rate data. + activity: The activity object associated with the stream, used to retrieve the user ID. + db: The database session or connection used to fetch user details. + Returns: + The activity stream object with an added 'hr_zone_percentages' attribute, which contains the percentage of time spent in each heart rate zone and their respective HR boundaries. If waypoints or user details are missing, returns the original activity stream unchanged. + Notes: + - Heart rate zones are calculated using the formula: max_heart_rate = 220 - age. + - The function expects waypoints to be a list of dicts with an "hr" key. + - If no valid heart rate data is present, the activity stream is returned as is. + """ + # Check if the activity stream has waypoints + waypoints = activity_stream.stream_waypoints + if not waypoints or not isinstance(waypoints, list): + # If there are no waypoints, return the activity stream as is + return activity_stream + + # Get the user details to calculate heart rate zones + detail_user = users_crud.get_user_by_id(activity.user_id, db) + if not detail_user or not detail_user.birthdate: + # If user details are not available or birthdate is missing, return the activity stream as is + return activity_stream + + # Calculate the maximum heart rate based on the user's birthdate + year = int(detail_user.birthdate.split("-")[0]) + current_year = datetime.datetime.now().year + max_heart_rate = 220 - (current_year - year) + + # Calculate heart rate zones based on the maximum heart rate + zone_1 = max_heart_rate * 0.5 + zone_2 = max_heart_rate * 0.6 + zone_3 = max_heart_rate * 0.7 + zone_4 = max_heart_rate * 0.8 + + # Extract heart rate values from waypoints + hr_values = np.array([wp.get("hr") for wp in waypoints if wp.get("hr") is not None]) + + # If there are no valid heart rate values, return the activity stream as is + total = len(hr_values) + if total == 0: + return activity_stream + + # Calculate the percentage of time spent in each heart rate zone + zone_counts = [ + np.sum(hr_values < zone_1), + np.sum((hr_values >= zone_1) & (hr_values < zone_2)), + np.sum((hr_values >= zone_2) & (hr_values < zone_3)), + np.sum((hr_values >= zone_3) & (hr_values < zone_4)), + np.sum(hr_values >= zone_4), + ] + zone_percentages = [round((count / total) * 100, 2) for count in zone_counts] + + # Calculate zone HR boundaries for display + zone_hr = { + "zone_1": f"< {int(zone_1)}", + "zone_2": f"{int(zone_1)} - {int(zone_2) - 1}", + "zone_3": f"{int(zone_2)} - {int(zone_3) - 1}", + "zone_4": f"{int(zone_3)} - {int(zone_4) - 1}", + "zone_5": f">= {int(zone_4)}", + } + activity_stream.hr_zone_percentages = { + "zone_1": {"percent": zone_percentages[0], "hr": zone_hr["zone_1"]}, + "zone_2": {"percent": zone_percentages[1], "hr": zone_hr["zone_2"]}, + "zone_3": {"percent": zone_percentages[2], "hr": zone_hr["zone_3"]}, + "zone_4": {"percent": zone_percentages[3], "hr": zone_hr["zone_4"]}, + "zone_5": {"percent": zone_percentages[4], "hr": zone_hr["zone_5"]}, + } + + return activity_stream + + def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: Session): try: # Check if public sharable links are enabled in server settings @@ -198,10 +384,8 @@ def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: S # Return None if public sharable links are disabled if not server_settings or not server_settings.public_shareable_links: return None - - activity = activity_crud.get_activity_by_id_if_is_public( - activity_id, db - ) + + activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db) if not activity: # If the activity does not exist, return None @@ -227,24 +411,51 @@ def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: S # Check if there is an activity stream; if not, return None if not activity_stream: return None - - if activity.hide_hr and activity_stream.stream_type == 1: + + if ( + activity.hide_hr + and activity_stream.stream_type == activity_streams_constants.STREAM_TYPE_HR + ): return None - if activity.hide_power and activity_stream.stream_type == 2: + if ( + activity.hide_power + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_POWER + ): return None - if activity.hide_cadence and activity_stream.stream_type == 3: + if ( + activity.hide_cadence + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_CADENCE + ): return None - if activity.hide_elevation and activity_stream.stream_type == 4: + if ( + activity.hide_elevation + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_ELEVATION + ): return None - if activity.hide_speed and activity_stream.stream_type == 5: + if ( + activity.hide_speed + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_SPEED + ): return None - if activity.hide_pace and activity_stream.stream_type == 6: + if ( + activity.hide_pace + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_PACE + ): return None - if activity.hide_map and activity_stream.stream_type == 7: + if ( + activity.hide_map + and activity_stream.stream_type + == activity_streams_constants.STREAM_TYPE_MAP + ): return None # Return the activity stream - return activity_stream + return transform_activity_streams(activity_stream, activity, db) except Exception as err: # Log the exception core_logger.print_to_log( diff --git a/backend/app/activities/activity_streams/schema.py b/backend/app/activities/activity_streams/schema.py index 3e4d2d750..54847f582 100644 --- a/backend/app/activities/activity_streams/schema.py +++ b/backend/app/activities/activity_streams/schema.py @@ -3,11 +3,29 @@ from typing import List class ActivityStreams(BaseModel): + """ + Represents a stream of activity data associated with an activity. + + Attributes: + id (int | None): Unique identifier for the activity stream (optional). + activity_id (int): Identifier of the related activity. + stream_type (int): Type of the stream (e.g., GPS, heart rate, etc.). + stream_waypoints (List[dict]): List of waypoints or data points in the stream. + strava_activity_stream_id (int | None): Identifier for the corresponding Strava activity stream (optional). + hr_zone_percentages (dict | None): Heart rate zone percentages for the activity (optional). + """ id: int | None = None activity_id: int stream_type: int stream_waypoints: List[dict] strava_activity_stream_id: int | None = None + hr_zone_percentages: dict | None = None class Config: + """ + Pydantic configuration class enabling ORM mode for model serialization. + + Attributes: + orm_mode (bool): When set to True, allows Pydantic models to read data from ORM objects. + """ orm_mode = True \ No newline at end of file diff --git a/backend/app/strava/activity_utils.py b/backend/app/strava/activity_utils.py index 0c5cc6d01..6d2bc0d28 100644 --- a/backend/app/strava/activity_utils.py +++ b/backend/app/strava/activity_utils.py @@ -646,8 +646,12 @@ def fetch_and_process_activity_laps( "total_elapsed_time": lap.elapsed_time, "total_timer_time": lap.moving_time, "total_distance": lap.distance, - "avg_heart_rate": round(lap.average_heartrate), - "max_heart_rate": round(lap.max_heartrate), + "avg_heart_rate": ( + round(lap.average_heartrate) if lap.average_heartrate else None + ), + "max_heart_rate": ( + round(lap.max_heartrate) if lap.max_heartrate else None + ), "avg_cadence": round(cad_avg) if cad_stream else None, "max_cadence": round(cad_max) if cad_stream else None, "avg_power": round(power_avg) if power_stream else None, diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 0d07e5775..2123a6d50 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/vue-fontawesome": "^3.0.8", "bootstrap": "^5.3.3", "chart.js": "^4.4.6", + "chartjs-plugin-datalabels": "^2.2.0", "flag-icons": "^7.2.3", "leaflet": "^1.9.4", "luxon": "^3.5.0", @@ -3885,6 +3886,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", diff --git a/frontend/app/package.json b/frontend/app/package.json index a7918b5af..1e7062547 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -19,6 +19,7 @@ "@fortawesome/vue-fontawesome": "^3.0.8", "bootstrap": "^5.3.3", "chart.js": "^4.4.6", + "chartjs-plugin-datalabels": "^2.2.0", "flag-icons": "^7.2.3", "leaflet": "^1.9.4", "luxon": "^3.5.0", diff --git a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue index 9f78b5de3..120f66857 100644 --- a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue @@ -1,14 +1,16 @@ @@ -209,50 +232,51 @@ import { useI18n } from "vue-i18n"; import ActivityLapsComponent from "@/components/Activities/ActivityLapsComponent.vue"; import ActivityStreamsLineChartComponent from "@/components/Activities/ActivityStreamsLineChartComponent.vue"; import ActivityWorkoutStepsComponent from "@/components/Activities/ActivityWorkoutStepsComponent.vue"; +import BarChartComponent from '@/components/GeneralComponents/BarChartComponent.vue'; // Import Notivue push import { push } from "notivue"; -import { useAuthStore } from "@/stores/authStore"; +// Import the utils +import { getHrBarChartData } from "@/utils/chartUtils"; import { formatPaceMetric, formatPaceImperial, formatPaceSwimMetric, formatPaceSwimImperial, formatAverageSpeedMetric, formatAverageSpeedImperial, activityTypeIsSwimming } from "@/utils/activityUtils"; import { formatSecondsToMinutes } from "@/utils/dateTimeUtils"; import { - metersToFeet, + metersToFeet, } from "@/utils/unitsUtils"; // Define props const props = defineProps({ - activity: { - type: Object, - required: true, - }, - activityActivityLaps: { - type: [Object, null], - required: true, - }, - activityActivityWorkoutSteps: { - type: [Object, null], - required: true, - }, - activityActivityStreams: { - type: [Object, null], - required: true, - }, - units: { - type: Number, - default: 1, - }, - activityActivityExerciseTitles: { - type: [Object, null], - required: true, - }, - activityActivitySets: { - type: [Object, null], - required: true, - }, + activity: { + type: Object, + required: true, + }, + activityActivityLaps: { + type: [Object, null], + required: true, + }, + activityActivityWorkoutSteps: { + type: [Object, null], + required: true, + }, + activityActivityStreams: { + type: [Object, null], + required: true, + }, + units: { + type: Number, + default: 1, + }, + activityActivityExerciseTitles: { + type: [Object, null], + required: true, + }, + activityActivitySets: { + type: [Object, null], + required: true, + }, }); // Setup composables and reactive data const { t } = useI18n(); -const authStore = useAuthStore(); const hrPresent = ref(false); const powerPresent = ref(false); const elePresent = ref(false); @@ -260,74 +284,85 @@ const cadPresent = ref(false); const velPresent = ref(false); const pacePresent = ref(false); const formattedPace = ref(null); +const hrZones = ref({ + zone_1: {}, + zone_2: {}, + zone_3: {}, + zone_4: {}, + zone_5: {}, +}); onMounted(async () => { - try { - if (props.activityActivityStreams && props.activityActivityStreams.length > 0) { - // Check if the activity has the streams - for (let i = 0; i < props.activityActivityStreams.length; i++) { - if (props.activityActivityStreams[i].stream_type === 1) { - hrPresent.value = true; - } - if (props.activityActivityStreams[i].stream_type === 2) { - powerPresent.value = true; - } - if (props.activityActivityStreams[i].stream_type === 3) { - cadPresent.value = true; - } - if (props.activityActivityStreams[i].stream_type === 4) { - elePresent.value = true; - } - if (props.activityActivityStreams[i].stream_type === 5) { - if ( - props.activity.activity_type === 4 || - props.activity.activity_type === 5 || - props.activity.activity_type === 6 || - props.activity.activity_type === 7 || - props.activity.activity_type === 27 - ) { - velPresent.value = true; - } - } - if (props.activityActivityStreams[i].stream_type === 6) { - if ( - props.activity.activity_type !== 4 && - props.activity.activity_type !== 5 && - props.activity.activity_type !== 6 && - props.activity.activity_type !== 7 && - props.activity.activity_type !== 27 - ) { - pacePresent.value = true; - } - } - } - } - } catch (error) { - // If there is an error, set the error message and show the error alert. - push.error( - `${t("activityMandAbovePillsComponent.errorMessageProcessingActivityStreams")} - ${error}`, - ); - } + try { + if (props.activityActivityStreams && props.activityActivityStreams.length > 0) { + // Check if the activity has the streams + for (let i = 0; i < props.activityActivityStreams.length; i++) { + if (props.activityActivityStreams[i].stream_type === 1) { + hrPresent.value = true; + } + if (props.activityActivityStreams[i].stream_type === 2) { + powerPresent.value = true; + } + if (props.activityActivityStreams[i].stream_type === 3) { + cadPresent.value = true; + } + if (props.activityActivityStreams[i].stream_type === 4) { + elePresent.value = true; + } + if (props.activityActivityStreams[i].stream_type === 5) { + if ( + props.activity.activity_type === 4 || + props.activity.activity_type === 5 || + props.activity.activity_type === 6 || + props.activity.activity_type === 7 || + props.activity.activity_type === 27 + ) { + velPresent.value = true; + } + } + if (props.activityActivityStreams[i].stream_type === 6) { + if ( + props.activity.activity_type !== 4 && + props.activity.activity_type !== 5 && + props.activity.activity_type !== 6 && + props.activity.activity_type !== 7 && + props.activity.activity_type !== 27 + ) { + pacePresent.value = true; + } + } + } + hrZones.value = props.activityActivityStreams.find(stream => stream.hr_zone_percentages).hr_zone_percentages || {}; + if (Object.keys(hrZones.value).length > 0) { + hrPresent.value = true; + } + } + } catch (error) { + // If there is an error, set the error message and show the error alert. + push.error( + `${t("activityMandAbovePillsComponent.errorMessageProcessingActivityStreams")} - ${error}`, + ); + } - try { - if ( - activityTypeIsSwimming(props.activity) || - props.activity.activity_type === 13 - ) { - if (Number(props.units) === 1) { - formattedPace.value = computed(() => formatPaceSwimMetric(props.activity.pace)); - } else { - formattedPace.value = computed(() => formatPaceSwimImperial(props.activity.pace)); - } - } else { - if (Number(props.units) === 1) { - formattedPace.value = computed(() => formatPaceMetric(props.activity.pace)); - } else { - formattedPace.value = computed(() => formatPaceImperial(props.activity.pace)); - } - } - } catch (error) { - push.error(`${t("activitySummaryComponent.errorFetchingUserById")} - ${error}`); - } + try { + if ( + activityTypeIsSwimming(props.activity) || + props.activity.activity_type === 13 + ) { + if (Number(props.units) === 1) { + formattedPace.value = computed(() => formatPaceSwimMetric(props.activity.pace)); + } else { + formattedPace.value = computed(() => formatPaceSwimImperial(props.activity.pace)); + } + } else { + if (Number(props.units) === 1) { + formattedPace.value = computed(() => formatPaceMetric(props.activity.pace)); + } else { + formattedPace.value = computed(() => formatPaceImperial(props.activity.pace)); + } + } + } catch (error) { + push.error(`${t("activitySummaryComponent.errorFetchingUserById")} - ${error}`); + } }); \ No newline at end of file diff --git a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue index 533eb301a..7ffde2b55 100644 --- a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue @@ -1,59 +1,88 @@ \ No newline at end of file diff --git a/frontend/app/src/components/GeneralComponents/BarChartComponent.vue b/frontend/app/src/components/GeneralComponents/BarChartComponent.vue new file mode 100644 index 000000000..c6e9eddbc --- /dev/null +++ b/frontend/app/src/components/GeneralComponents/BarChartComponent.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/app/src/i18n/us/components/activities/activityMandAbovePillsComponent.json b/frontend/app/src/i18n/us/components/activities/activityMandAbovePillsComponent.json index 691b2b072..cb681bd82 100644 --- a/frontend/app/src/i18n/us/components/activities/activityMandAbovePillsComponent.json +++ b/frontend/app/src/i18n/us/components/activities/activityMandAbovePillsComponent.json @@ -4,11 +4,13 @@ "labelPillWorkoutSets": "Sets", "labelGraph": "Activity data graphs", "labelGraphHR": "Heart rate", + "labelHRZones": "Heart rate zones", "labelGraphPower": "Power", "labelGraphCadence": "Cadence", "labelGraphElevation": "Elevation", "labelGraphVelocity": "Speed", "labelGraphPace": "Pace", + "labelGraphHRZone": "Zone", "labelDownsampling": "Data downsampled to ~200 points", "errorMessageProcessingActivityStreams": "Error processing activity streams", "labelGraphStrokeRate": "Stroke rate" diff --git a/frontend/app/src/i18n/us/components/activities/activitySummaryComponent.json b/frontend/app/src/i18n/us/components/activities/activitySummaryComponent.json index 0e08465f4..7b3db3711 100644 --- a/frontend/app/src/i18n/us/components/activities/activitySummaryComponent.json +++ b/frontend/app/src/i18n/us/components/activities/activitySummaryComponent.json @@ -10,14 +10,12 @@ "labelVirtual": "(Virtual) ", "activityDistance": "Distance", "activityTime": "Time", - "activityElevationGain": "Elevation Gain", "activityPace": "Pace", - "activityAvgPower": "Avg Power", - "activityAvgSpeed": "Avg Speed", "activityAvgHR": "Avg HR", - "activityMaxHR": "Max HR", - "activityEleGain": "Elevation gain", - "activityEleLoss": "Elevation loss", + "activityAvgPower": "Avg power", + "activityAvgSpeed": "Avg speed", + "activityEleGain": "Ele gain", + "activityEleLoss": "Ele loss", "activityCalories": "Calories", "activityNoData": "No data", "errorFetchingUserById": "Error fetching user by id", diff --git a/frontend/app/src/utils/chartUtils.js b/frontend/app/src/utils/chartUtils.js new file mode 100644 index 000000000..b61a1d952 --- /dev/null +++ b/frontend/app/src/utils/chartUtils.js @@ -0,0 +1,21 @@ +export function getZoneColor(index) { + // Example colors for 5 HR zones + const colors = [ + '#1e90ff', // Zone 1: blue + '#28a745', // Zone 2: green + '#ffc107', // Zone 3: yellow + '#fd7e14', // Zone 4: orange + '#dc3545', // Zone 5: red + ]; + return colors[index] || '#000'; +} + +export function getHrBarChartData(hrZones, t) { + const zones = Object.values(hrZones); + return { + labels: zones.map((z, i) => `${t('activityMandAbovePillsComponent.labelGraphHRZone')} ${i + 1} (${z.hr || ''})`), + // values: zones.map(z => `${z.percent ?? 0}%`), + values: zones.map(z => z.percent ?? 0), + barColors: zones.map((_, i) => getZoneColor(i)), + }; +} \ No newline at end of file