Add HR Zones chart to mobile, move logic to chartUtils

[frontend] moved chart functions to chartUtils file
[frontend] added HR Zones Bar Chart to mobile view
This commit is contained in:
João Vitória Silva
2025-06-23 16:27:44 +01:00
parent e475138d11
commit 6cfe4999e1
3 changed files with 180 additions and 145 deletions

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

@@ -57,10 +57,13 @@
<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().labels"
:values="getHrBarChartData().values" :barColors="getHrBarChartData().barColors"
:datalabelsFormatter="(value) => `${Math.round(value)}%`"
:title="$t('activityMandAbovePillsComponent.labelHRZones')" />
<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>
@@ -91,9 +94,10 @@ import ActivityStreamsLineChartComponent from "@/components/Activities/ActivityS
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({
@@ -129,7 +133,6 @@ const props = defineProps({
// Composables
const { t } = useI18n();
const authStore = useAuthStore();
// Reactive state
const graphSelection = ref("hr");
@@ -141,11 +144,11 @@ 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: {},
zone_1: {},
zone_2: {},
zone_3: {},
zone_4: {},
zone_5: {},
});
// Methods
@@ -208,8 +211,6 @@ onMounted(async () => {
}
}
}
}
if (props.activityActivityStreams && props.activityActivityStreams.length > 0) {
hrZones.value = props.activityActivityStreams.find(stream => stream.hr_zone_percentages).hr_zone_percentages || {};
if (Object.keys(hrZones.value).length > 0) {
hrPresent.value = true;
@@ -226,26 +227,4 @@ onMounted(async () => {
);
}
});
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';
}
function getHrBarChartData() {
const zones = Object.values(hrZones.value);
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)),
};
}
</script>

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