feat: show HR based on zone that calculated from user birthdate (if any)

This commit is contained in:
Zuhdi
2025-06-15 11:32:40 +07:00
parent 65b1bea870
commit bd1ec74968
8 changed files with 183 additions and 52 deletions

View File

@@ -0,0 +1,10 @@
# 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
STREAM_TYPE_HR_ZONES = 11

View File

@@ -1,5 +1,7 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
import numpy as np
import datetime
import activities.activity_streams.schema as activity_streams_schema
import activities.activity_streams.models as activity_streams_models
@@ -10,8 +12,12 @@ 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
import activities.activity_streams.constants as stream_constants
def get_activity_streams(activity_id: int, token_user_id: int, db: Session):
try:
@@ -44,18 +50,18 @@ def get_activity_streams(activity_id: int, token_user_id: int, db: Session):
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 == stream_constants.STREAM_TYPE_HR) or
(activity.hide_power and stream.stream_type == stream_constants.STREAM_TYPE_POWER) or
(activity.hide_cadence and stream.stream_type == stream_constants.STREAM_TYPE_CADENCE) or
(activity.hide_elevation and stream.stream_type == stream_constants.STREAM_TYPE_ELEVATION) or
(activity.hide_speed and stream.stream_type == stream_constants.STREAM_TYPE_SPEED) or
(activity.hide_pace and stream.stream_type == stream_constants.STREAM_TYPE_PACE) or
(activity.hide_map and stream.stream_type == stream_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(
@@ -108,18 +114,18 @@ def get_public_activity_streams(activity_id: int, db: Session):
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 == stream_constants.STREAM_TYPE_HR) or
(activity.hide_power and stream.stream_type == stream_constants.STREAM_TYPE_POWER) or
(activity.hide_cadence and stream.stream_type == stream_constants.STREAM_TYPE_CADENCE) or
(activity.hide_elevation and stream.stream_type == stream_constants.STREAM_TYPE_ELEVATION) or
(activity.hide_speed and stream.stream_type == stream_constants.STREAM_TYPE_SPEED) or
(activity.hide_pace and stream.stream_type == stream_constants.STREAM_TYPE_PACE) or
(activity.hide_map and stream.stream_type == stream_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(
@@ -161,23 +167,23 @@ def get_activity_stream_by_type(activity_id: int, stream_type: int, token_user_i
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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_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(
@@ -189,6 +195,77 @@ def get_activity_stream_by_type(activity_id: int, stream_type: int, token_user_i
detail="Internal Server Error",
) from err
def transform_activity_streams(activity_stream, activity, db):
print("Transforming activity stream")
"""
Transform the activity stream based on the stream type.
"""
if activity_stream.stream_type == stream_constants.STREAM_TYPE_HR:
return transform_activity_streams_hr(activity_stream, activity, db)
else:
return activity_stream
def transform_activity_streams_hr(activity_stream, activity, db):
print("Transforming HR activity stream")
"""
Transform the activity stream for heart rate.
Calculate the percentage of time spent in each HR zone using numpy for performance.
"""
detail_user = users_crud.get_user_by_id(activity.user_id, db)
print(f"Detail user: {detail_user}")
if not detail_user or not detail_user.birthdate:
return activity_stream
year = int(detail_user.birthdate.split("-")[0])
current_year = datetime.datetime.now().year
max_heart_rate = 220 - (current_year - year)
print(f"Max heart rate: {max_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
print(f"Heart rate zones: {zone_1}, {zone_2}, {zone_3}, {zone_4}")
waypoints = activity_stream.stream_waypoints
if not waypoints or not isinstance(waypoints, list):
return activity_stream
hr_values = np.array([wp.get("hr") for wp in waypoints if wp.get("hr") is not None])
total = len(hr_values)
print(f"Total HR values: {total}")
if total == 0:
return activity_stream
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"]},
}
print(f"HR zone percentages: {activity_stream.hr_zone_percentages}")
return activity_stream
def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: Session):
try:
@@ -228,19 +305,19 @@ def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: S
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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_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 == stream_constants.STREAM_TYPE_MAP:
return None
# Return the activity stream

View File

@@ -8,6 +8,7 @@ class ActivityStreams(BaseModel):
stream_type: int
stream_waypoints: List[dict]
strava_activity_stream_id: int | None = None
hr_zone_percentages: dict | None = None
class Config:
orm_mode = True

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "endurain"
version = "0.12.2"
version = "0.13.0"
description = "Endurain API for the Endurain app"
authors = ["João Vitória Silva <8648976+joaovitoriasilva@users.noreply.github.com>"]
readme = "README.md"
@@ -39,6 +39,7 @@ user-agents = "^2.2.0"
pydantic = {extras = ["email"], version = "^2.11.3"}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -124,16 +124,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 +142,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 +161,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 &&

View File

@@ -41,7 +41,9 @@
</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">
-
@@ -95,7 +97,7 @@
<!-- 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 border-start border-opacity-50" 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>
@@ -116,7 +118,10 @@
{{ $t("activitySummaryComponent.activityTime") }}
</span>
<br>
<span>{{ formatSecondsToMinutes(activity.total_elapsed_time) }}</span>
<span>
{{$t('activitySummaryComponent.activityMovingTime')}}: {{ formatSecondsToMinutes(activity.total_timer_time) }} <br>
{{$t('activitySummaryComponent.activityTotalTime')}}: {{ formatSecondsToMinutes(activity.total_elapsed_time) }}
</span>
</div>
<div class="col border-start border-opacity-50">
<!-- elevation -->
@@ -135,19 +140,11 @@
<br>
{{ formatPace(activity, authStore.user.units) }}
</div>
<!-- avg_hr -->
<div v-else>
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityAvgHR") }}
</span>
<br>
<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">
<!-- 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 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.activityAvgPower") }}
</span>
@@ -193,6 +190,23 @@
<span>{{ formatCalories(activity.calories) }}</span>
</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 class="col border-start border-opacity-50">
<!-- hr -->
<div>
<span class="fw-lighter">
{{ $t("activitySummaryComponent.activityHR") }}
</span>
<br>
<span>{{ $t("activitySummaryComponent.activityAvgHR") }}: {{ formatHr(activity.average_hr) }}</span> <br>
<span>{{ $t("activitySummaryComponent.activityMaxHR") }}: {{ formatHr(activity.max_hr) }}</span> <br><br>
<span v-for="(value, zone, index) in hrZones" :key="zone"
:style="{ color: getZoneColor(index) }">
{{ $t("activitySummaryComponent.activityHRZone") }} {{ index + 1 }} ({{ value.hr }}) : {{ value.percent }}%<br>
</span>
</div>
</div>
</div>
</div>
</template>
@@ -237,6 +251,10 @@ const props = defineProps({
type: Object,
required: true,
},
activityActivityStreams: {
type: [Object, null],
required: true,
},
source: {
type: String,
required: true,
@@ -259,10 +277,18 @@ const { t } = useI18n();
// Reactive data
const isLoading = ref(true);
const userActivity = ref(null);
const hrZones = ref({
zone_1: 0,
zone_2: 0,
zone_3: 0,
zone_4: 0,
});
// Lifecycle
onMounted(async () => {
try {
hrZones.value = props.activityActivityStreams.find(stream => stream.hr_zone_percentages).hr_zone_percentages || {};
if (authStore.isAuthenticated) {
userActivity.value = await users.getUserById(props.activity.user_id);
} else {
@@ -297,4 +323,16 @@ function updateActivityFieldsOnEdit(data) {
// Emit the activityEditedFields event to the parent component
emit("activityEditedFields", data);
}
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';
}
</script>

View File

@@ -10,12 +10,16 @@
"labelVirtual": "(Virtual) ",
"activityDistance": "Distance",
"activityTime": "Time",
"activityTotalTime": "Total Time",
"activityMovingTime": "Moving Time",
"activityElevationGain": "Elevation Gain",
"activityPace": "Pace",
"activityAvgPower": "Avg Power",
"activityAvgSpeed": "Avg Speed",
"activityHR": "Heart Rate",
"activityAvgHR": "Avg HR",
"activityMaxHR": "Max HR",
"activityHRZone": "Zone",
"activityEleGain": "Elevation gain",
"activityEleLoss": "Elevation loss",
"activityCalories": "Calories",

View File

@@ -3,7 +3,7 @@
<LoadingComponent v-if="isLoading"/>
<div v-else>
<ActivitySummaryComponent v-if="activity" :activity="activity" :source="'activity'" @activityEditedFields="updateActivityFieldsOnEdit" :units="units" />
<ActivitySummaryComponent v-if="activity" :activity="activity" :activityActivityStreams="activityActivityStreams" :source="'activity'" @activityEditedFields="updateActivityFieldsOnEdit" :units="units" />
<AlertComponent v-if="activity && activity.user_id === authStore.user.id && (activity.hide_start_time || activity.hide_location || activity.hide_map || activity.hide_hr || activity.hide_power || activity.hide_cadence || activity.hide_elevation || activity.hide_speed || activity.hide_pace || activity.hide_laps || activity.hide_workout_sets_steps || activity.hide_gear)" :message="alertPrivacyMessage" :dismissible="true" class="mt-2"/>
</div>