From 317922d416e9a11ed5bd2ae07fc6566d0faa03fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Boris=20Sta=CC=88heli?= Date: Sat, 13 Dec 2025 22:42:23 +0100 Subject: [PATCH 1/5] Add time duration display to heart rate zones - Backend: Calculate time_seconds for each HR zone based on waypoint ratio - Frontend: Display both percentage and time (e.g., '25% (15m)') in HR zone charts - Update BarChartComponent to support timeSeconds prop - Use activity total_timer_time for accurate time calculation --- .../app/activities/activity_streams/crud.py | 19 ++++++++++++++----- .../ActivityBellowMPillsComponent.vue | 11 ++++++++++- .../ActivityMandAbovePillsComponent.vue | 11 ++++++++++- .../GeneralComponents/BarChartComponent.vue | 4 ++++ frontend/app/src/utils/chartUtils.js | 17 ++++++++++++++++- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/backend/app/activities/activity_streams/crud.py b/backend/app/activities/activity_streams/crud.py index cc5db12d4..64cdf302d 100644 --- a/backend/app/activities/activity_streams/crud.py +++ b/backend/app/activities/activity_streams/crud.py @@ -424,6 +424,15 @@ def transform_activity_streams_hr(activity_stream, activity, db): np.sum(hr_values >= zone_4), ] zone_percentages = [round((count / total) * 100, 2) for count in zone_counts] + + # Calculate time in seconds for each zone + # Use the same logic as percentage: distribute total_timer_time based on waypoint ratio + if hasattr(activity, 'total_timer_time') and activity.total_timer_time and total > 0: + total_time_seconds = activity.total_timer_time + zone_time_seconds = [int((count / total) * total_time_seconds) for count in zone_counts] + else: + # Fallback: assume waypoints represent equal time intervals + zone_time_seconds = [int(count) for count in zone_counts] # Calculate zone HR boundaries for display zone_hr = { @@ -434,11 +443,11 @@ def transform_activity_streams_hr(activity_stream, activity, db): "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"]}, + "zone_1": {"percent": zone_percentages[0], "hr": zone_hr["zone_1"], "time_seconds": zone_time_seconds[0]}, + "zone_2": {"percent": zone_percentages[1], "hr": zone_hr["zone_2"], "time_seconds": zone_time_seconds[1]}, + "zone_3": {"percent": zone_percentages[2], "hr": zone_hr["zone_3"], "time_seconds": zone_time_seconds[2]}, + "zone_4": {"percent": zone_percentages[3], "hr": zone_hr["zone_4"], "time_seconds": zone_time_seconds[3]}, + "zone_5": {"percent": zone_percentages[4], "hr": zone_hr["zone_5"], "time_seconds": zone_time_seconds[4]}, } return activity_stream diff --git a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue index 4c7a8614a..7c85bfbf6 100644 --- a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue @@ -141,7 +141,16 @@ :labels="getHrBarChartData(hrZones, t).labels" :values="getHrBarChartData(hrZones, t).values" :barColors="getHrBarChartData(hrZones, t).barColors" - :datalabelsFormatter="(value) => `${Math.round(value)}%`" + :timeSeconds="getHrBarChartData(hrZones, t).timeSeconds" + :datalabelsFormatter=" + (value, context) => { + const timeSeconds = getHrBarChartData(hrZones, t).timeSeconds[context.dataIndex] + const hours = Math.floor(timeSeconds / 3600) + const minutes = Math.floor((timeSeconds % 3600) / 60) + const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m` + return `${Math.round(value)}% (${timeStr})` + } + " :title="$t('activityMandAbovePillsComponent.labelHRZones')" />
diff --git a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue index 7df75802e..26d57c8e4 100644 --- a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue @@ -123,7 +123,16 @@ :labels="getHrBarChartData(hrZones, t).labels" :values="getHrBarChartData(hrZones, t).values" :barColors="getHrBarChartData(hrZones, t).barColors" - :datalabelsFormatter="(value) => `${Math.round(value)}%`" + :timeSeconds="getHrBarChartData(hrZones, t).timeSeconds" + :datalabelsFormatter=" + (value, context) => { + const timeSeconds = getHrBarChartData(hrZones, t).timeSeconds[context.dataIndex] + const hours = Math.floor(timeSeconds / 3600) + const minutes = Math.floor((timeSeconds % 3600) / 60) + const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m` + return `${Math.round(value)}% (${timeStr})` + } + " :title="$t('activityMandAbovePillsComponent.labelHRZones')" /> diff --git a/frontend/app/src/components/GeneralComponents/BarChartComponent.vue b/frontend/app/src/components/GeneralComponents/BarChartComponent.vue index 7a7c805eb..8ec4b9b3b 100644 --- a/frontend/app/src/components/GeneralComponents/BarChartComponent.vue +++ b/frontend/app/src/components/GeneralComponents/BarChartComponent.vue @@ -31,6 +31,10 @@ const props = defineProps({ datalabelsFormatter: { type: Function, default: null + }, + timeSeconds: { + type: Array, + default: () => [] } }) diff --git a/frontend/app/src/utils/chartUtils.js b/frontend/app/src/utils/chartUtils.js index 83e3bf651..e86a486f5 100644 --- a/frontend/app/src/utils/chartUtils.js +++ b/frontend/app/src/utils/chartUtils.js @@ -1,3 +1,17 @@ +/** + * Formats seconds into a human-readable duration string. + * @param {number} seconds - The total number of seconds to format. + * @returns {string} Formatted duration string (e.g., "2h 30m" or "45m"). + */ +function formatDuration(seconds) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 0) { + return `${hours}h ${minutes}m` + } + return `${minutes}m` +} + export function getZoneColor(index) { // Example colors for 5 HR zones const colors = [ @@ -18,6 +32,7 @@ export function getHrBarChartData(hrZones, t) { ), // values: zones.map(z => `${z.percent ?? 0}%`), values: zones.map((z) => z.percent ?? 0), - barColors: zones.map((_, i) => getZoneColor(i)) + barColors: zones.map((_, i) => getZoneColor(i)), + timeSeconds: zones.map((z) => z.time_seconds ?? 0) } } From 6b6a6fdf5336ad6d5e5fa98d8f07897acac1d73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Boris=20Sta=CC=88heli?= Date: Sat, 13 Dec 2025 23:19:46 +0100 Subject: [PATCH 2/5] Refactor time calculation for heart rate zones and update display logic in activity components --- backend/app/activities/activity_streams/crud.py | 11 +++++------ .../Activities/ActivityBellowMPillsComponent.vue | 3 +++ .../Activities/ActivityMandAbovePillsComponent.vue | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/activities/activity_streams/crud.py b/backend/app/activities/activity_streams/crud.py index 64cdf302d..e1e32d864 100644 --- a/backend/app/activities/activity_streams/crud.py +++ b/backend/app/activities/activity_streams/crud.py @@ -425,14 +425,13 @@ def transform_activity_streams_hr(activity_stream, activity, db): ] zone_percentages = [round((count / total) * 100, 2) for count in zone_counts] - # Calculate time in seconds for each zone - # Use the same logic as percentage: distribute total_timer_time based on waypoint ratio - if hasattr(activity, 'total_timer_time') and activity.total_timer_time and total > 0: + # Calculate time in seconds for each zone using the percentage of total_timer_time + if hasattr(activity, 'total_timer_time') and activity.total_timer_time: total_time_seconds = activity.total_timer_time - zone_time_seconds = [int((count / total) * total_time_seconds) for count in zone_counts] + zone_time_seconds = [int((percent / 100) * total_time_seconds) for percent in zone_percentages] else: - # Fallback: assume waypoints represent equal time intervals - zone_time_seconds = [int(count) for count in zone_counts] + # Fallback: no time calculation possible without total_timer_time + zone_time_seconds = [0, 0, 0, 0, 0] # Calculate zone HR boundaries for display zone_hr = { diff --git a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue index 7c85bfbf6..6064cc981 100644 --- a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue @@ -145,6 +145,9 @@ :datalabelsFormatter=" (value, context) => { const timeSeconds = getHrBarChartData(hrZones, t).timeSeconds[context.dataIndex] + if (!timeSeconds || timeSeconds === 0) { + return `${Math.round(value)}%` + } const hours = Math.floor(timeSeconds / 3600) const minutes = Math.floor((timeSeconds % 3600) / 60) const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m` diff --git a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue index 26d57c8e4..3aee0d06a 100644 --- a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue @@ -127,6 +127,9 @@ :datalabelsFormatter=" (value, context) => { const timeSeconds = getHrBarChartData(hrZones, t).timeSeconds[context.dataIndex] + if (!timeSeconds || timeSeconds === 0) { + return `${Math.round(value)}%` + } const hours = Math.floor(timeSeconds / 3600) const minutes = Math.floor((timeSeconds % 3600) / 60) const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m` From c18114431e86879bb0d2b3cf3adebf86f3cdcc89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Boris=20Sta=CC=88heli?= Date: Sat, 13 Dec 2025 23:24:20 +0100 Subject: [PATCH 3/5] Address PR review comments - Export formatDuration function from chartUtils.js - Add formatHrZoneLabel utility function to eliminate duplication - Use computed properties for HR chart data to avoid repeated calculations - Fix PEP8 line length violations (79 chars for code, 72 for comments) - Format backend code with proper line breaks for readability --- .../app/activities/activity_streams/crud.py | 48 ++++++++++++++----- .../ActivityBellowMPillsComponent.vue | 22 ++++----- .../ActivityMandAbovePillsComponent.vue | 26 ++++------ frontend/app/src/utils/chartUtils.js | 17 ++++++- 4 files changed, 71 insertions(+), 42 deletions(-) diff --git a/backend/app/activities/activity_streams/crud.py b/backend/app/activities/activity_streams/crud.py index e1e32d864..fb468bc5c 100644 --- a/backend/app/activities/activity_streams/crud.py +++ b/backend/app/activities/activity_streams/crud.py @@ -423,14 +423,20 @@ def transform_activity_streams_hr(activity_stream, activity, db): 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 time in seconds for each zone using the percentage of total_timer_time - if hasattr(activity, 'total_timer_time') and activity.total_timer_time: + zone_percentages = [ + round((count / total) * 100, 2) for count in zone_counts + ] + + # Calculate time in seconds for each zone using the percentage + # of total_timer_time + if hasattr(activity, "total_timer_time") and activity.total_timer_time: total_time_seconds = activity.total_timer_time - zone_time_seconds = [int((percent / 100) * total_time_seconds) for percent in zone_percentages] + zone_time_seconds = [ + int((percent / 100) * total_time_seconds) + for percent in zone_percentages + ] else: - # Fallback: no time calculation possible without total_timer_time + # Fallback: no time calculation possible zone_time_seconds = [0, 0, 0, 0, 0] # Calculate zone HR boundaries for display @@ -442,11 +448,31 @@ def transform_activity_streams_hr(activity_stream, activity, db): "zone_5": f">= {int(zone_4)}", } activity_stream.hr_zone_percentages = { - "zone_1": {"percent": zone_percentages[0], "hr": zone_hr["zone_1"], "time_seconds": zone_time_seconds[0]}, - "zone_2": {"percent": zone_percentages[1], "hr": zone_hr["zone_2"], "time_seconds": zone_time_seconds[1]}, - "zone_3": {"percent": zone_percentages[2], "hr": zone_hr["zone_3"], "time_seconds": zone_time_seconds[2]}, - "zone_4": {"percent": zone_percentages[3], "hr": zone_hr["zone_4"], "time_seconds": zone_time_seconds[3]}, - "zone_5": {"percent": zone_percentages[4], "hr": zone_hr["zone_5"], "time_seconds": zone_time_seconds[4]}, + "zone_1": { + "percent": zone_percentages[0], + "hr": zone_hr["zone_1"], + "time_seconds": zone_time_seconds[0], + }, + "zone_2": { + "percent": zone_percentages[1], + "hr": zone_hr["zone_2"], + "time_seconds": zone_time_seconds[1], + }, + "zone_3": { + "percent": zone_percentages[2], + "hr": zone_hr["zone_3"], + "time_seconds": zone_time_seconds[2], + }, + "zone_4": { + "percent": zone_percentages[3], + "hr": zone_hr["zone_4"], + "time_seconds": zone_time_seconds[3], + }, + "zone_5": { + "percent": zone_percentages[4], + "hr": zone_hr["zone_5"], + "time_seconds": zone_time_seconds[4], + }, } return activity_stream diff --git a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue index 6064cc981..7af6dd1df 100644 --- a/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityBellowMPillsComponent.vue @@ -138,21 +138,12 @@ @@ -358,6 +349,9 @@ const pacePresent = ref(false) const formattedPace = ref(null) const hrZones = ref({}) +// Computed properties +const hrChartData = computed(() => getHrBarChartData(hrZones.value, t)) + onMounted(async () => { try { if (props.activityActivityStreams && props.activityActivityStreams.length > 0) { diff --git a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue index 3aee0d06a..13cef7082 100644 --- a/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue +++ b/frontend/app/src/components/Activities/ActivityMandAbovePillsComponent.vue @@ -120,21 +120,12 @@ /> @@ -179,7 +170,7 @@