Merge branch 'pr/199' into pre-release

This commit is contained in:
João Vitória Silva
2025-06-23 16:28:37 +01:00
13 changed files with 772 additions and 295 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,14 +1,16 @@
<template>
<div if="activity" class="fw-lighter">
<!-- laps -->
<ActivityLapsComponent :activity="activity" :activityActivityLaps="activityActivityLaps" :units="units" v-if="activityActivityLaps && activityActivityLaps.length > 0"/>
<ActivityLapsComponent :activity="activity" :activityActivityLaps="activityActivityLaps" :units="units"
v-if="activityActivityLaps && activityActivityLaps.length > 0" />
<!-- Pace values -->
<div v-if="pacePresent">
<span class="fw-normal">
{{ $t("activityBellowMPillsComponent.subTitlePace") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'pace'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'pace'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="formattedPace">
<span>
{{ $t("activityBellowMPillsComponent.labelAvgPace") }}
@@ -40,14 +42,19 @@
<span class="fw-normal">
{{ $t("activityBellowMPillsComponent.subTitleSpeed") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'vel'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'vel'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="activity.average_speed">
<span>
{{ $t("activityBellowMPillsComponent.labelAvgSpeed") }}
</span>
<span>
<span v-if="activity.average_speed && Number(units) === 1"><b>{{ formatAverageSpeedMetric(activity.average_speed) }}{{ ' ' + $t("generalItems.unitsKmH") }}</b></span>
<span v-else-if="activity.average_speed && Number(units) === 2"><b>{{ formatAverageSpeedImperial(activity.average_speed) }}{{ ' ' + $t("generalItems.unitsMph") }}</b></span>
<span v-if="activity.average_speed && Number(units) === 1"><b>{{
formatAverageSpeedMetric(activity.average_speed) }}{{ ' ' + $t("generalItems.unitsKmH")
}}</b></span>
<span v-else-if="activity.average_speed && Number(units) === 2"><b>{{
formatAverageSpeedImperial(activity.average_speed) }}{{ ' ' + $t("generalItems.unitsMph")
}}</b></span>
</span>
</div>
<div class="d-flex justify-content-between mt-3" v-if="activity.max_speed">
@@ -55,8 +62,12 @@
{{ $t("activityBellowMPillsComponent.labelMaxSpeed") }}
</span>
<span>
<span v-if="activity.max_speed && Number(units) === 1"><b>{{ formatAverageSpeedMetric(activity.max_speed) }}{{ ' ' + $t("generalItems.unitsKmH") }}</b></span>
<span v-else-if="activity.max_speed && Number(units) === 2"><b>{{ formatAverageSpeedImperial(activity.max_speed) }}{{ ' ' + $t("generalItems.unitsMph") }}</b></span>
<span v-if="activity.max_speed && Number(units) === 1"><b>{{
formatAverageSpeedMetric(activity.max_speed) }}{{ ' ' + $t("generalItems.unitsKmH")
}}</b></span>
<span v-else-if="activity.max_speed && Number(units) === 2"><b>{{
formatAverageSpeedImperial(activity.max_speed) }}{{ ' ' + $t("generalItems.unitsMph")
}}</b></span>
</span>
</div>
<div class="d-flex justify-content-between mt-3" v-if="activity.total_elapsed_time">
@@ -82,7 +93,8 @@
<span class="fw-normal">
{{ $t("activityBellowMPillsComponent.subTitleHeartRate") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'hr'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'hr'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="activity.average_hr">
<span>
{{ $t("activityBellowMPillsComponent.labelAvgHeartRate") }}
@@ -99,6 +111,11 @@
<b>{{ activity.max_hr }}{{ ' ' + $t("generalItems.unitsBpm") }}</b>
</span>
</div>
<BarChartComponent v-if="Object.values(hrZones).length > 0 && hrPresent"
:labels="getHrBarChartData(hrZones, t).labels" :values="getHrBarChartData(hrZones, t).values"
:barColors="getHrBarChartData(hrZones, t).barColors"
:datalabelsFormatter="(value) => `${Math.round(value)}%`"
:title="$t('activityMandAbovePillsComponent.labelHRZones')" />
<hr>
</div>
<!-- Power values -->
@@ -106,7 +123,8 @@
<span class="fw-normal">
{{ $t("activityBellowMPillsComponent.subTitlePower") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'power'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'power'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="activity.average_power">
<span>
{{ $t("activityBellowMPillsComponent.labelAvgPower") }}
@@ -141,7 +159,8 @@
<span class="fw-normal" v-else>
{{ $t("activityBellowMPillsComponent.subTitleStrokeRate") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'cad'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'cad'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="activity.average_cad">
<span v-if="!activityTypeIsSwimming(activity)">
{{ $t("activityBellowMPillsComponent.labelAvgCadence") }}
@@ -171,7 +190,8 @@
<span class="fw-normal">
{{ $t("activityBellowMPillsComponent.subTitleElevation") }}
</span>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'ele'" :activityStreams="activityActivityStreams" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="'ele'"
:activityStreams="activityActivityStreams" />
<div class="d-flex justify-content-between mt-3" v-if="activity.elevation_gain">
<span>
{{ $t("activityBellowMPillsComponent.labelElevationGain") }}
@@ -197,8 +217,11 @@
<hr>
</div>
<!-- sets -->
<ActivityWorkoutStepsComponent :activity="activity" :activityActivityWorkoutSteps="activityActivityWorkoutSteps" :units="units" :activityActivityExerciseTitles="activityActivityExerciseTitles" :activityActivitySets="activityActivitySets" v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0"/>
<!-- sets -->
<ActivityWorkoutStepsComponent :activity="activity" :activityActivityWorkoutSteps="activityActivityWorkoutSteps"
:units="units" :activityActivityExerciseTitles="activityActivityExerciseTitles"
:activityActivitySets="activityActivitySets"
v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0" />
</div>
</template>
@@ -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}`);
}
});
</script>

View File

@@ -1,59 +1,88 @@
<template>
<ul class="nav nav-pills mb-3 mt-3 justify-content-center" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation" v-if="graphItems && graphItems.length > 0">
<button class="nav-link link-body-emphasis" :class="{ active: graphItems || graphItems.length > 0 }" id="pills-graphs-tab" data-bs-toggle="pill" data-bs-target="#pills-graphs" type="button" role="tab" aria-controls="pills-graphs" :aria-selected="graphItems && graphItems.length > 0 ? true : false">
{{ $t("activityMandAbovePillsComponent.labelPillGraphs") }}
</button>
</li>
<li class="nav-item" role="presentation" v-if="activityActivityLaps && activityActivityLaps.length > 0">
<button class="nav-link link-body-emphasis" :class="{ active: !graphItems || graphItems.length === 0 }" id="pills-laps-tab" data-bs-toggle="pill" data-bs-target="#pills-laps" type="button" role="tab" aria-controls="pills-laps" :aria-selected="!graphItems || graphItems.length === 0 ? 'true' : 'false'">
{{ $t("activityMandAbovePillsComponent.labelPillLaps") }}
</button>
</li>
<li class="nav-item" role="presentation" v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0">
<button class="nav-link link-body-emphasis" id="pills-workout-steps-tab" data-bs-toggle="pill" data-bs-target="#pills-workout-steps" type="button" role="tab" aria-controls="pills-workout-steps" aria-selected="false">
{{ $t("activityMandAbovePillsComponent.labelPillWorkoutSets") }}
</button>
</li>
</ul>
<ul class="nav nav-pills mb-3 mt-3 justify-content-center" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation" v-if="graphItems && graphItems.length > 0">
<button class="nav-link link-body-emphasis" :class="{ active: graphItems || graphItems.length > 0 }"
id="pills-graphs-tab" data-bs-toggle="pill" data-bs-target="#pills-graphs" type="button" role="tab"
aria-controls="pills-graphs" :aria-selected="graphItems && graphItems.length > 0 ? true : false">
{{ $t("activityMandAbovePillsComponent.labelPillGraphs") }}
</button>
</li>
<li class="nav-item" role="presentation" v-if="activityActivityLaps && activityActivityLaps.length > 0">
<button class="nav-link link-body-emphasis" :class="{ active: !graphItems || graphItems.length === 0 }"
id="pills-laps-tab" data-bs-toggle="pill" data-bs-target="#pills-laps" type="button" role="tab"
aria-controls="pills-laps" :aria-selected="!graphItems || graphItems.length === 0 ? 'true' : 'false'">
{{ $t("activityMandAbovePillsComponent.labelPillLaps") }}
</button>
</li>
<li class="nav-item" role="presentation"
v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0">
<button class="nav-link link-body-emphasis" id="pills-workout-steps-tab" data-bs-toggle="pill"
data-bs-target="#pills-workout-steps" type="button" role="tab" aria-controls="pills-workout-steps"
aria-selected="false">
{{ $t("activityMandAbovePillsComponent.labelPillWorkoutSets") }}
</button>
</li>
</ul>
<div class="tab-content" id="pills-tabContent">
<div class="tab-pane fade show" :class="{ active: graphItems || graphItems.length > 0 }" id="pills-graphs" role="tabpanel" aria-labelledby="pills-graphs-tab" tabindex="0" v-if="graphItems && graphItems.length > 0">
<div class="row">
<div class="col-md-2">
<p>{{ $t("activityMandAbovePillsComponent.labelGraph") }}</p>
<ul class="nav nav-pills flex-column mb-auto" id="sidebarLineGraph">
<li class="nav-item" v-for="item in graphItems" :key="item.type">
<a href="javascript:void(0);" class="nav-link text-secondary"
:class="{ 'active text-white': graphSelection === item.type }"
@click="selectGraph(item.type)">
{{ item.label }}
</a>
</li>
</ul>
<p class="mt-2">{{ $t("activityMandAbovePillsComponent.labelDownsampling") }}</p>
</div>
<div class="col">
<div if="activity">
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'hr' && hrPresent"/>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'power' && powerPresent"/>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'cad' && cadPresent"/>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'ele' && elePresent"/>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'vel' && velPresent"/>
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection" :activityStreams="activityActivityStreams" v-if="graphSelection === 'pace' && pacePresent"/>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': !graphItems || graphItems.length === 0 }" id="pills-laps" role="tabpanel" aria-labelledby="pills-laps-tab" tabindex="1" v-if="activityActivityLaps && activityActivityLaps.length > 0">
<ActivityLapsComponent :activity="activity" :activityActivityLaps="activityActivityLaps" :units="units" />
</div>
<div class="tab-pane fade" id="pills-workout-steps" role="tabpanel" aria-labelledby="pills-workout-steps-tab" tabindex="2" v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0">
<ActivityWorkoutStepsComponent :activity="activity" :activityActivityWorkoutSteps="activityActivityWorkoutSteps" :units="units" :activityActivityExerciseTitles="activityActivityExerciseTitles" :activityActivitySets="activityActivitySets" />
<div class="tab-content" id="pills-tabContent">
<div class="tab-pane fade show" :class="{ active: graphItems || graphItems.length > 0 }" id="pills-graphs"
role="tabpanel" aria-labelledby="pills-graphs-tab" tabindex="0" v-if="graphItems && graphItems.length > 0">
<div class="row">
<div class="col-md-2">
<p>{{ $t("activityMandAbovePillsComponent.labelGraph") }}</p>
<ul class="nav nav-pills flex-column mb-auto" id="sidebarLineGraph">
<li class="nav-item" v-for="item in graphItems" :key="item.type">
<a href="javascript:void(0);" class="nav-link text-secondary"
:class="{ 'active text-white': graphSelection === item.type }"
@click="selectGraph(item.type)">
{{ item.label }}
</a>
</li>
</ul>
<p class="mt-2">{{ $t("activityMandAbovePillsComponent.labelDownsampling") }}</p>
</div>
<div class="col">
<div if="activity">
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams" v-if="graphSelection === 'hr' && hrPresent" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams"
v-if="graphSelection === 'power' && powerPresent" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams" v-if="graphSelection === 'cad' && cadPresent" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams" v-if="graphSelection === 'ele' && elePresent" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams" v-if="graphSelection === 'vel' && velPresent" />
<ActivityStreamsLineChartComponent :activity="activity" :graphSelection="graphSelection"
:activityStreams="activityActivityStreams"
v-if="graphSelection === 'pace' && pacePresent" />
<BarChartComponent
v-if="Object.values(hrZones).length > 0 && graphSelection === 'hrZones' && hrPresent"
:labels="getHrBarChartData(hrZones, t).labels"
:values="getHrBarChartData(hrZones, t).values"
:barColors="getHrBarChartData(hrZones, t).barColors"
:datalabelsFormatter="(value) => `${Math.round(value)}%`"
:title="$t('activityMandAbovePillsComponent.labelHRZones')" />
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" :class="{ 'show active': !graphItems || graphItems.length === 0 }" id="pills-laps"
role="tabpanel" aria-labelledby="pills-laps-tab" tabindex="1"
v-if="activityActivityLaps && activityActivityLaps.length > 0">
<ActivityLapsComponent :activity="activity" :activityActivityLaps="activityActivityLaps" :units="units" />
</div>
<div class="tab-pane fade" id="pills-workout-steps" role="tabpanel" aria-labelledby="pills-workout-steps-tab"
tabindex="2" v-if="activityActivityWorkoutSteps && activityActivityWorkoutSteps.length > 0">
<ActivityWorkoutStepsComponent :activity="activity"
:activityActivityWorkoutSteps="activityActivityWorkoutSteps" :units="units"
:activityActivityExerciseTitles="activityActivityExerciseTitles"
:activityActivitySets="activityActivitySets" />
</div>
</div>
</template>
<script setup>
@@ -63,10 +92,12 @@ 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 { activityTypeIsSwimming } from "@/utils/activityUtils";
import { useAuthStore } from "@/stores/authStore";
// Import Notivue push
import { push } from "notivue";
// Import the utils
import { getHrBarChartData } from "@/utils/chartUtils";
// Props
const props = defineProps({
@@ -102,7 +133,6 @@ const props = defineProps({
// Composables
const { t } = useI18n();
const authStore = useAuthStore();
// Reactive state
const graphSelection = ref("hr");
@@ -113,6 +143,13 @@ const elePresent = ref(false);
const cadPresent = ref(false);
const velPresent = ref(false);
const pacePresent = ref(false);
const hrZones = ref({
zone_1: {},
zone_2: {},
zone_3: {},
zone_4: {},
zone_5: {},
});
// Methods
function selectGraph(type) {
@@ -124,16 +161,16 @@ 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) {
for (const element of props.activityActivityStreams) {
if (element.stream_type === 1) {
hrPresent.value = true;
graphItems.value.push({ type: "hr", label: `${t("activityMandAbovePillsComponent.labelGraphHR")}` });
}
if (props.activityActivityStreams[i].stream_type === 2) {
if (element.stream_type === 2) {
powerPresent.value = true;
graphItems.value.push({ type: "power", label: `${t("activityMandAbovePillsComponent.labelGraphPower")}` });
}
if (props.activityActivityStreams[i].stream_type === 3) {
if (element.stream_type === 3) {
cadPresent.value = true;
// Label as "Stroke Rate" over "Cadence" for swimming activities
if (activityTypeIsSwimming(props.activity)) {
@@ -142,14 +179,14 @@ onMounted(async () => {
graphItems.value.push({ type: "cad", label: `${t("activityMandAbovePillsComponent.labelGraphCadence")}` });
}
}
if (props.activityActivityStreams[i].stream_type === 4) {
if (element.stream_type === 4) {
// Do not show elevation for swimming activities
if (!activityTypeIsSwimming(props.activity)) {
elePresent.value = true;
graphItems.value.push({ type: "ele", label: `${t("activityMandAbovePillsComponent.labelGraphElevation")}` });
}
}
if (props.activityActivityStreams[i].stream_type === 5) {
if (element.stream_type === 5) {
velPresent.value = true;
if (
props.activity.activity_type === 4 ||
@@ -161,7 +198,7 @@ onMounted(async () => {
graphItems.value.push({ type: "vel", label: `${t("activityMandAbovePillsComponent.labelGraphVelocity")}` });
}
}
if (props.activityActivityStreams[i].stream_type === 6) {
if (element.stream_type === 6) {
pacePresent.value = true;
if (
props.activity.activity_type !== 4 &&
@@ -174,6 +211,11 @@ onMounted(async () => {
}
}
}
hrZones.value = props.activityActivityStreams.find(stream => stream.hr_zone_percentages).hr_zone_percentages || {};
if (Object.keys(hrZones.value).length > 0) {
hrPresent.value = true;
graphItems.value.push({ type: "hrZones", label: `${t("activityMandAbovePillsComponent.labelHRZones")}` });
}
}
if (graphItems.value.length > 0) {
graphSelection.value = graphItems.value[0].type;

View File

@@ -6,15 +6,19 @@
<div class="d-flex justify-content-between">
<!-- user name and photo zone -->
<div class="d-flex align-items-center">
<UserAvatarComponent :user="userActivity" :width=55 :height=55 />
<UserAvatarComponent :user="userActivity" :width=55 :height=55 />
<div class="ms-3 me-3">
<div class="fw-bold">
<router-link :to="{ name: 'activity', params: { id: activity.id }}" class="link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover" v-if="source === 'home'">
{{ activity.name}}
<router-link :to="{ name: 'activity', params: { id: activity.id } }"
class="link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
v-if="source === 'home'">
{{ activity.name }}
</router-link>
<span v-if="userActivity">
<router-link :to="{ name: 'user', params: { id: userActivity.id }}" class="link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover" v-if="source === 'activity'">
{{ userActivity.name}}
<router-link :to="{ name: 'user', params: { id: userActivity.id } }"
class="link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
v-if="source === 'activity'">
{{ userActivity.name }}
</router-link>
</span>
<span v-else>
@@ -24,52 +28,68 @@
<h6>
<!-- Display the visibility of the activity -->
<span v-if="activity.visibility == 0">
<font-awesome-icon :icon="['fas', 'globe']"/> {{ $t("activitySummaryComponent.visibilityPublic") }}
<font-awesome-icon :icon="['fas', 'globe']" /> {{
$t("activitySummaryComponent.visibilityPublic") }}
</span>
<span v-if="activity.visibility == 1">
<font-awesome-icon :icon="['fas', 'users']" v-if="activity.visibility == 1" /> {{ $t("activitySummaryComponent.visibilityFollowers") }}
<font-awesome-icon :icon="['fas', 'users']" v-if="activity.visibility == 1" /> {{
$t("activitySummaryComponent.visibilityFollowers") }}
</span>
<span v-if="activity.visibility == 2">
<font-awesome-icon :icon="['fas', 'lock']" v-if="activity.visibility == 2" /> {{ $t("activitySummaryComponent.visibilityPrivate") }}
<font-awesome-icon :icon="['fas', 'lock']" v-if="activity.visibility == 2" /> {{
$t("activitySummaryComponent.visibilityPrivate") }}
</span>
<span> - </span>
<!-- Display the activity type -->
<span>
<font-awesome-icon class="me-1" :icon="getIcon(activity.activity_type)" />
<span v-if="activity.activity_type === 3 || activity.activity_type === 7">{{ $t("activitySummaryComponent.labelVirtual") }}</span>
<span v-if="activity.activity_type === 3 || activity.activity_type === 7">{{
$t("activitySummaryComponent.labelVirtual") }}</span>
</span>
<!-- Display the date and time -->
<span v-if="activity.start_time">{{ formatDateMed(activity.start_time) }} @ {{ formatTime(activity.start_time) }}</span>
<span v-if="activity.start_time">
{{ formatDateMed(activity.start_time) }} @ {{ formatTime(activity.start_time) }}
</span>
<!-- Conditionally display city and country -->
<span v-if="activity.town || activity.city || activity.country">
-
-
<span>{{ formatLocation(activity) }}</span>
</span>
</h6>
</div>
</div>
<div class="dropdown d-flex" v-if="activity.user_id == authStore.user.id">
<a class="btn btn-link btn-lg link-body-emphasis" :href="`https://www.strava.com/activities/${activity.strava_activity_id}`" role="button" v-if="activity.strava_activity_id">
<a class="btn btn-link btn-lg link-body-emphasis"
:href="`https://www.strava.com/activities/${activity.strava_activity_id}`" role="button"
v-if="activity.strava_activity_id">
<font-awesome-icon :icon="['fab', 'fa-strava']" />
</a>
<a class="btn btn-link btn-lg link-body-emphasis" :href="`https://connect.garmin.com/modern/activity/${activity.garminconnect_activity_id}`" role="button" v-if="activity.garminconnect_activity_id">
<img src="/src/assets/garminconnect/Garmin_Connect_app_1024x1024-02.png" alt="Garmin Connect logo" height="22" />
<a class="btn btn-link btn-lg link-body-emphasis"
:href="`https://connect.garmin.com/modern/activity/${activity.garminconnect_activity_id}`"
role="button" v-if="activity.garminconnect_activity_id">
<img src="/src/assets/garminconnect/Garmin_Connect_app_1024x1024-02.png" alt="Garmin Connect logo"
height="22" />
</a>
<div>
<button class="btn btn-link btn-lg link-body-emphasis" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button class="btn btn-link btn-lg link-body-emphasis" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<font-awesome-icon :icon="['fas', 'fa-ellipsis-vertical']" />
</button>
<ul class="dropdown-menu">
<li v-if="source === 'activity'">
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#editActivityModal">
<a class="dropdown-item" href="#" data-bs-toggle="modal"
data-bs-target="#editActivityModal">
{{ $t("activitySummaryComponent.buttonEditActivity") }}
</a>
</li>
<li v-if="source === 'activity'"><hr class="dropdown-divider"></li>
<li v-if="source === 'activity'">
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#deleteActivityModal">
<a class="dropdown-item" href="#" data-bs-toggle="modal"
data-bs-target="#deleteActivityModal">
{{ $t("activitySummaryComponent.buttonDeleteActivity") }}
</a>
</li>
@@ -79,10 +99,13 @@
</div>
<!-- Modal edit activity -->
<EditActivityModalComponent :activity="activity" @activityEditedFields="updateActivityFieldsOnEdit"/>
<EditActivityModalComponent :activity="activity" @activityEditedFields="updateActivityFieldsOnEdit" />
<!-- Modal delete activity -->
<ModalComponent modalId="deleteActivityModal" :title="t('activitySummaryComponent.buttonDeleteActivity')" :body="`${t('activitySummaryComponent.modalDeleteBody1')}<b>${activity.name}</b>?<br>${t('activitySummaryComponent.modalDeleteBody2')}`" :actionButtonType="`danger`" :actionButtonText="t('activitySummaryComponent.buttonDeleteActivity')" @submitAction="submitDeleteActivity"/>
<ModalComponent modalId="deleteActivityModal" :title="t('activitySummaryComponent.buttonDeleteActivity')"
:body="`${t('activitySummaryComponent.modalDeleteBody1')}<b>${activity.name}</b>?<br>${t('activitySummaryComponent.modalDeleteBody2')}`"
:actionButtonType="`danger`" :actionButtonText="t('activitySummaryComponent.buttonDeleteActivity')"
@submitAction="submitDeleteActivity" />
<!-- Activity title -->
<h1 class="mt-3" v-if="source === 'activity'">
@@ -95,7 +118,8 @@
<!-- Activity summary -->
<div class="row mt-3 align-items-center text-start">
<!-- distance -->
<div class="col" v-if="activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<div class="col"
v-if="activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityDistance") }}
</span>
@@ -120,15 +144,17 @@
</div>
<div class="col border-start border-opacity-50">
<!-- elevation -->
<div v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 8 && activity.activity_type != 9 && activity.activity_type != 10 && activity.activity_type != 13 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<div
v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 8 && activity.activity_type != 9 && activity.activity_type != 10 && activity.activity_type != 13 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityElevationGain") }}
{{ $t("activitySummaryComponent.activityEleGain") }}
</span>
<br>
<span>{{ formatElevation(activity.elevation_gain, authStore.user.units) }}</span>
</div>
<!-- pace -->
<div v-else-if="activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<div
v-else-if="activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityPace") }}
</span>
@@ -144,10 +170,12 @@
<span>{{ formatHr(activity.average_hr) }}</span>
</div>
</div>
</div>
<div class="row d-flex mt-3" v-if="source === 'activity' && activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
</div>
<div class="row d-flex mt-3"
v-if="source === 'activity' && activity.activity_type != 10 && activity.activity_type != 14 && activity.activity_type != 18 && activity.activity_type != 19 && activity.activity_type != 20 && activity.activity_type != 21 && activity.activity_type != 22 && activity.activity_type != 23 && activity.activity_type != 24 && activity.activity_type != 25 && activity.activity_type != 26">
<!-- avg_power running and cycling activities-->
<div class="col" v-if="activity.activity_type == 1 || activity.activity_type == 2 || activity.activity_type == 3 || activity.activity_type == 4 || activity.activity_type == 5 || activity.activity_type == 6 || activity.activity_type == 7 || activity.activity_type == 27">
<div class="col"
v-if="activity.activity_type == 1 || activity.activity_type == 2 || activity.activity_type == 3 || activity.activity_type == 4 || activity.activity_type == 5 || activity.activity_type == 6 || activity.activity_type == 7 || activity.activity_type == 27">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityAvgPower") }}
</span>
@@ -155,7 +183,8 @@
<span>{{ formatPower(activity.average_power) }}</span>
</div>
<!-- avg_hr not running and cycling activities-->
<div class="col" v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 4 && activity.activity_type != 5 && activity.activity_type != 6 && activity.activity_type != 7 && activity.activity_type != 27">
<div class="col"
v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 4 && activity.activity_type != 5 && activity.activity_type != 6 && activity.activity_type != 7 && activity.activity_type != 27">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityAvgHR") }}
</span>
@@ -163,7 +192,8 @@
<span>{{ formatHr(activity.average_hr) }}</span>
</div>
<!-- max_hr not running and cycling activities-->
<div class="col border-start border-opacity-50" v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 4 && activity.activity_type != 5 && activity.activity_type != 6 && activity.activity_type != 7 && activity.activity_type != 27">
<div class="col border-start border-opacity-50"
v-if="activity.activity_type != 1 && activity.activity_type != 2 && activity.activity_type != 3 && activity.activity_type != 4 && activity.activity_type != 5 && activity.activity_type != 6 && activity.activity_type != 7 && activity.activity_type != 27">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityMaxHR") }}
</span>
@@ -171,13 +201,15 @@
<span>{{ formatHr(activity.max_hr) }}</span>
</div>
<!-- ele gain running activities -->
<div class="col border-start border-opacity-50" v-if="activity.activity_type == 1 || activity.activity_type == 2 || activity.activity_type == 3">
<div class="col border-start border-opacity-50"
v-if="activity.activity_type == 1 || activity.activity_type == 2 || activity.activity_type == 3">
<span class="fw-lighter">{{ $t("activitySummaryComponent.activityEleGain") }}</span>
<br>
<span>{{ formatElevation(activity.elevation_gain, authStore.user.units) }}</span>
</div>
<!-- avg_speed cycling activities -->
<div class="col border-start border-opacity-50" v-if="activity.activity_type == 4 || activity.activity_type == 5 || activity.activity_type == 6 || activity.activity_type == 7 || activity.activity_type == 27">
<div class="col border-start border-opacity-50"
v-if="activity.activity_type == 4 || activity.activity_type == 5 || activity.activity_type == 6 || activity.activity_type == 7 || activity.activity_type == 27">
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityAvgSpeed") }}
</span>
@@ -210,37 +242,38 @@ import LoadingComponent from "@/components/GeneralComponents/LoadingComponent.vu
import UserAvatarComponent from "@/components/Users/UserAvatarComponent.vue";
import EditActivityModalComponent from "@/components/Activities/Modals/EditActivityModalComponent.vue";
import ModalComponent from "@/components/Modals/ModalComponent.vue";
import BarChartComponent from '@/components/GeneralComponents/BarChartComponent.vue';
// Importing the services
import { users } from "@/services/usersService";
import { activities } from "@/services/activitiesService";
// Importing the utils
import {
formatDistance,
formatElevation,
formatPace,
formatHr,
formatCalories,
getIcon,
formatLocation,
formatDistance,
formatElevation,
formatPace,
formatHr,
formatCalories,
getIcon,
formatLocation,
formatAverageSpeed,
formatPower,
} from "@/utils/activityUtils";
import {
formatDateMed,
formatTime,
formatDateMed,
formatTime,
formatSecondsToMinutes,
} from "@/utils/dateTimeUtils";
// Props
const props = defineProps({
activity: {
type: Object,
required: true,
},
source: {
type: String,
required: true,
},
activity: {
type: Object,
required: true,
},
source: {
type: String,
required: true,
},
units: {
type: Number,
default: 1,
@@ -262,7 +295,7 @@ const userActivity = ref(null);
// Lifecycle
onMounted(async () => {
try {
try {
if (authStore.isAuthenticated) {
userActivity.value = await users.getUserById(props.activity.user_id);
} else {
@@ -270,17 +303,17 @@ onMounted(async () => {
userActivity.value = await users.getPublicUserById(props.activity.user_id);
}
}
} catch (error) {
push.error(`${t("activitySummaryComponent.errorFetchingUserById")} - ${error}`);
} finally {
isLoading.value = false;
}
} catch (error) {
push.error(`${t("activitySummaryComponent.errorFetchingUserById")} - ${error}`);
} finally {
isLoading.value = false;
}
});
// Methods
async function submitDeleteActivity() {
try {
userActivity.value = await activities.deleteActivity(props.activity.id);
try {
userActivity.value = await activities.deleteActivity(props.activity.id);
if (props.source === 'activity') {
return router.push({
path: "/",
@@ -288,13 +321,13 @@ async function submitDeleteActivity() {
});
}
emit("activityDeleted", props.activity.id);
} catch (error) {
push.error(`${t("activitySummaryComponent.errorDeletingActivity")} - ${error}`);
}
} catch (error) {
push.error(`${t("activitySummaryComponent.errorDeletingActivity")} - ${error}`);
}
}
function updateActivityFieldsOnEdit(data) {
// Emit the activityEditedFields event to the parent component
emit("activityEditedFields", data);
// Emit the activityEditedFields event to the parent component
emit("activityEditedFields", data);
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<canvas ref="barChartCanvas"></canvas>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { Chart, registerables } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
Chart.register(...registerables);
const props = defineProps({
labels: {
type: Array,
required: true
},
values: {
type: Array,
required: true
},
barColors: {
type: Array,
default: () => ['#1e90ff', '#28a745', '#ffc107', '#fd7e14', '#dc3545']
},
title: {
type: String,
default: ''
},
datalabelsFormatter: {
type: Function,
default: null
}
});
const barChartCanvas = ref(null);
let chartInstance = null;
function renderChart() {
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(barChartCanvas.value, {
type: 'bar',
data: {
labels: props.labels,
datasets: [
{
label: props.title,
data: props.values,
backgroundColor: props.barColors,
}
]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { display: false },
title: {
display: props.title,
text: props.title
},
tooltip: { enabled: false },
datalabels: {
backgroundColor: function(context) {
return "black";
},
borderRadius: 4,
color: 'white',
align: 'end', // Align datalabels to the end of the bar
anchor: 'end', // Anchor datalabels to the end of the bar
formatter: props.datalabelsFormatter || undefined,
padding: 6
}
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 10 }
}
}
},
plugins: [ChartDataLabels]
});
}
onMounted(() => {
renderChart();
});
watch(() => [props.labels, props.values, props.barColors, props.title], renderChart, { deep: true });
</script>

View File

@@ -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"

View File

@@ -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",

View File

@@ -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)),
};
}