Add HealthSleepAddEditModalComponent and refactor sleep zone

Introduces HealthSleepAddEditModalComponent for adding and editing sleep records. Refactors HealthSleepZone.vue to use the new modal, improves code formatting and readability across health and activity components, and updates event handling for sleep data management.
This commit is contained in:
João Vitória Silva
2025-12-05 16:17:45 +00:00
parent a4d52a8e03
commit 2683f38cea
149 changed files with 2370 additions and 805 deletions

View File

@@ -298,7 +298,7 @@ import {
activityTypeIsSailing,
activityTypeNotSailing,
activityTypeIsWindsurf,
activityTypeNotWindsurf,
activityTypeNotWindsurf
} from '@/utils/activityUtils'
import { formatSecondsToMinutes } from '@/utils/dateTimeUtils'
import { metersToFeet } from '@/utils/unitsUtils'
@@ -370,12 +370,20 @@ onMounted(async () => {
elePresent.value = true
}
if (props.activityActivityStreams[i].stream_type === 5) {
if (activityTypeIsCycling(props.activity) || activityTypeIsSailing(props.activity) || activityTypeIsWindsurf(props.activity)) {
if (
activityTypeIsCycling(props.activity) ||
activityTypeIsSailing(props.activity) ||
activityTypeIsWindsurf(props.activity)
) {
velPresent.value = true
}
}
if (props.activityActivityStreams[i].stream_type === 6) {
if (activityTypeNotCycling(props.activity) && activityTypeNotSailing(props.activity) && activityTypeNotWindsurf(props.activity)) {
if (
activityTypeNotCycling(props.activity) &&
activityTypeNotSailing(props.activity) &&
activityTypeNotWindsurf(props.activity)
) {
pacePresent.value = true
}
}

View File

@@ -181,7 +181,7 @@ import {
activityTypeIsSailing,
activityTypeNotSailing,
activityTypeIsWindsurf,
activityTypeNotWindsurf,
activityTypeNotWindsurf
} from '@/utils/activityUtils'
// Import Notivue push
import { push } from 'notivue'
@@ -299,7 +299,11 @@ onMounted(async () => {
}
if (element.stream_type === 5) {
velPresent.value = true
if (activityTypeIsCycling(props.activity) || activityTypeIsSailing(props.activity) || activityTypeIsWindsurf(props.activity)) {
if (
activityTypeIsCycling(props.activity) ||
activityTypeIsSailing(props.activity) ||
activityTypeIsWindsurf(props.activity)
) {
graphItems.value.push({
type: 'vel',
label: `${t('activityMandAbovePillsComponent.labelGraphVelocity')}`
@@ -308,7 +312,11 @@ onMounted(async () => {
}
if (element.stream_type === 6) {
pacePresent.value = true
if (activityTypeNotCycling(props.activity) && activityTypeNotSailing(props.activity) && activityTypeNotWindsurf(props.activity)) {
if (
activityTypeNotCycling(props.activity) &&
activityTypeNotSailing(props.activity) &&
activityTypeNotWindsurf(props.activity)
) {
graphItems.value.push({
type: 'pace',
label: `${t('activityMandAbovePillsComponent.labelGraphPace')}`

View File

@@ -232,7 +232,6 @@ const initMap = () => {
attribution: '© OpenStreetMap contributors'
}).addTo(leafletMap.value)
const polyline = L.polyline(latlngs, {
color: '#2563eb',
weight: 4,

View File

@@ -18,7 +18,7 @@ import {
activityTypeIsSwimming,
activityTypeIsRunning,
activityTypeIsRowing,
activityTypeIsWalking,
activityTypeIsWalking
} from '@/utils/activityUtils'
import { metersToFeet, kmToMiles } from '@/utils/unitsUtils'
@@ -197,7 +197,11 @@ export default {
} else {
// Compute converted pace (minutes per km or per mile for running, walking and rowing, minutes per 100m/100yd for swimming)
let converted = null
if (activityTypeIsRunning(props.activity) || activityTypeIsWalking(props.activity) || activityTypeIsRowing(props.activity)) {
if (
activityTypeIsRunning(props.activity) ||
activityTypeIsWalking(props.activity) ||
activityTypeIsRowing(props.activity)
) {
if (Number(units.value) === 1) {
converted = (paceData.pace * 1000) / 60 // min/km
} else {
@@ -226,7 +230,11 @@ export default {
}
}
}
if (activityTypeIsRunning(props.activity) || activityTypeIsWalking(props.activity) || activityTypeIsRowing(props.activity)) {
if (
activityTypeIsRunning(props.activity) ||
activityTypeIsWalking(props.activity) ||
activityTypeIsRowing(props.activity)
) {
if (Number(units.value) === 1) {
label = t('generalItems.labelPaceInMinKm')
} else {

View File

@@ -73,11 +73,7 @@
role="button"
v-if="activity.garminconnect_activity_id"
>
<img
:src="INTEGRATION_LOGOS.garminConnectApp"
alt="Garmin Connect logo"
height="22"
/>
<img :src="INTEGRATION_LOGOS.garminConnectApp" alt="Garmin Connect logo" height="22" />
</a>
<div>
<button
@@ -251,12 +247,7 @@
{{ formatPace(t, activity, authStore.user.units) }}
</div>
<!-- avg_speed sailing activities -->
<div
v-else-if="
activityTypeIsWindsurf(activity) ||
activityTypeIsSailing(activity)
"
>
<div v-else-if="activityTypeIsWindsurf(activity) || activityTypeIsSailing(activity)">
<span class="fw-lighter">
{{ $t('activitySummaryComponent.activityAvgSpeed') }}
</span>

View File

@@ -303,7 +303,9 @@
</select>
<!-- hide map fields -->
<label for="activityHideMapEdit"
><b>* {{ $t('editActivityModalComponent.modalEditActivityHideMapLabel') }}</b></label
><b
>* {{ $t('editActivityModalComponent.modalEditActivityHideMapLabel') }}</b
></label
>
<select
class="form-select"
@@ -416,7 +418,9 @@
</div>
<!-- hide laps fields -->
<label for="activityHideLapsEdit"
><b>* {{ $t('editActivityModalComponent.modalEditActivityHideLapsLabel') }}</b></label
><b
>* {{ $t('editActivityModalComponent.modalEditActivityHideLapsLabel') }}</b
></label
>
<select
class="form-select"
@@ -431,7 +435,9 @@
<label for="activityHideWorkoutSetsStepsEdit"
><b
>*
{{ $t('editActivityModalComponent.modalEditActivityHideWorkoutSetsStepsLabel') }}</b
{{
$t('editActivityModalComponent.modalEditActivityHideWorkoutSetsStepsLabel')
}}</b
></label
>
<select
@@ -445,7 +451,9 @@
</select>
<!-- hide gear fields -->
<label for="activityHideGearEdit"
><b>* {{ $t('editActivityModalComponent.modalEditActivityHideGearLabel') }}</b></label
><b
>* {{ $t('editActivityModalComponent.modalEditActivityHideGearLabel') }}</b
></label
>
<select
class="form-select"

View File

@@ -26,11 +26,7 @@
v0.16.0
</p>
<p class="text-center text-muted">
<img
:src="INTEGRATION_LOGOS.strava"
alt="Compatible with STRAVA image"
height="25"
/>
<img :src="INTEGRATION_LOGOS.strava" alt="Compatible with STRAVA image" height="25" />
<img
class="ms-2"

View File

@@ -13,8 +13,11 @@
</div>
<div class="card-footer text-body-secondary">
<span v-if="userHealthTargets && userHealthTargets['sleep']">
<font-awesome-icon :icon="['fas', 'angle-down']" class="me-1"
v-if="todaySleep < userHealthTargets.sleep" />
<font-awesome-icon
:icon="['fas', 'angle-down']"
class="me-1"
v-if="todaySleep < userHealthTargets.sleep"
/>
<font-awesome-icon :icon="['fas', 'angle-up']" class="me-1" v-else />
{{ formatDuration(userHealthTargets.sleep) }}
</span>
@@ -29,7 +32,9 @@
<h4>{{ $t('healthDashboardZoneComponent.restingHeartRate') }}</h4>
</div>
<div class="card-body">
<h1 v-if="restingHeartRate">{{ restingHeartRate }} {{ $t('generalItems.unitsBpm') }}</h1>
<h1 v-if="restingHeartRate">
{{ restingHeartRate }} {{ $t('generalItems.unitsBpm') }}
</h1>
<h1 v-else>{{ $t('generalItems.labelNoData') }}</h1>
</div>
<div class="card-footer text-body-secondary">
@@ -45,7 +50,9 @@
<h4>{{ $t('healthDashboardZoneComponent.avgSkinTemperatureDeviation') }}</h4>
</div>
<div class="card-body">
<h1 v-if="avgSkinTempDeviation">{{ avgSkinTempDeviation }} {{ $t('generalItems.unitsCelsius') }}</h1>
<h1 v-if="avgSkinTempDeviation">
{{ avgSkinTempDeviation }} {{ $t('generalItems.unitsCelsius') }}
</h1>
<h1 v-else>{{ $t('generalItems.labelNoData') }}</h1>
</div>
<div class="card-footer text-body-secondary">
@@ -69,13 +76,28 @@
<h1 v-else>{{ $t('generalItems.labelNotApplicable') }}</h1>
</div>
<div class="card-footer text-body-secondary">
<font-awesome-icon :icon="['fas', 'angle-down']" class="me-1"
v-if="currentWeight > userHealthTargets.weight" />
<font-awesome-icon
:icon="['fas', 'angle-down']"
class="me-1"
v-if="currentWeight > userHealthTargets.weight"
/>
<font-awesome-icon :icon="['fas', 'angle-up']" class="me-1" v-else />
<span v-if="userHealthTargets && userHealthTargets['weight'] && Number(authStore?.user?.units) === 1">
<span
v-if="
userHealthTargets &&
userHealthTargets['weight'] &&
Number(authStore?.user?.units) === 1
"
>
{{ userHealthTargets.weight }} {{ $t('generalItems.unitsKg') }}
</span>
<span v-else-if="userHealthTargets && userHealthTargets['weight'] && Number(authStore?.user?.units) === 2">
<span
v-else-if="
userHealthTargets &&
userHealthTargets['weight'] &&
Number(authStore?.user?.units) === 2
"
>
{{ kgToLbs(userHealthTargets.weight) }} {{ $t('generalItems.unitsLbs') }}
</span>
<span v-else>{{ $t('healthDashboardZoneComponent.noWeightTarget') }}</span>
@@ -113,10 +135,14 @@
</div>
<div class="card-footer text-body-secondary">
<span v-if="userHealthTargets && userHealthTargets['steps']">
<font-awesome-icon :icon="['fas', 'angle-down']" class="me-1"
v-if="todaySteps < userHealthTargets.steps" />
<font-awesome-icon
:icon="['fas', 'angle-down']"
class="me-1"
v-if="todaySteps < userHealthTargets.steps"
/>
<font-awesome-icon :icon="['fas', 'angle-up']" class="me-1" v-else />
{{ userHealthTargets.steps }} {{ $t('healthDashboardZoneComponent.stepsTargetLabel') }}
{{ userHealthTargets.steps }}
{{ $t('healthDashboardZoneComponent.stepsTargetLabel') }}
</span>
<span v-else>{{ $t('healthDashboardZoneComponent.noStepsTarget') }}</span>
</div>
@@ -211,4 +237,4 @@ onMounted(async () => {
}
}
})
</script>
</script>

View File

@@ -3,27 +3,40 @@
<LoadingComponent v-if="isLoading" />
<div v-else>
<!-- Checking if userHealthSleep is loaded and has length -->
<div v-if="userHealthSleep && userHealthSleep.length" class="p-3 bg-body-tertiary rounded shadow-sm">
<div
v-if="userHealthSleep && userHealthSleep.length"
class="p-3 bg-body-tertiary rounded shadow-sm"
>
<!-- show graph -->
<HealthRHRLineChartComponent :userHealthSleep="userHealthSleep" :isLoading="isLoading" />
<br />
<p>
{{ $t('healthRHRZoneComponent.labelNumberOfHealthRHR1')
}}{{ userHealthSleep.length
{{ $t('healthRHRZoneComponent.labelNumberOfHealthRHR1') }}{{ userHealthSleep.length
}}{{ $t('healthRHRZoneComponent.labelNumberOfHealthRHR2')
}}{{ userHealthSleepPagination.length
}}{{ $t('healthRHRZoneComponent.labelNumberOfHealthRHR3') }}
</p>
<!-- list zone -->
<ul class="my-3 list-group list-group-flush" v-for="userHealthSleep in userHealthSleepPagination"
:key="userHealthSleep.id" :userHealthSleep="userHealthSleep">
<HealthRHRListComponent :userHealthSleep="userHealthSleep" v-if="userHealthSleep.resting_heart_rate" />
<ul
class="my-3 list-group list-group-flush"
v-for="userHealthSleep in userHealthSleepPagination"
:key="userHealthSleep.id"
:userHealthSleep="userHealthSleep"
>
<HealthRHRListComponent
:userHealthSleep="userHealthSleep"
v-if="userHealthSleep.resting_heart_rate"
/>
</ul>
<!-- pagination area -->
<PaginationComponent :totalPages="totalPages" :pageNumber="pageNumber" @pageNumberChanged="setPageNumber" />
<PaginationComponent
:totalPages="totalPages"
:pageNumber="pageNumber"
@pageNumberChanged="setPageNumber"
/>
</div>
<!-- Displaying a message or component when there are no RHR measurements -->
<div v-else>
@@ -68,4 +81,4 @@ const emit = defineEmits(['pageNumberChanged'])
function setPageNumber(page) {
emit('pageNumberChanged', page)
}
</script>
</script>

View File

@@ -84,14 +84,11 @@ const chartData = computed(() => {
for (const healthSleep of sortedSleep) {
data.push(healthSleep.resting_heart_rate)
const createdAt = new Date(healthSleep.date)
labels.push(
`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`
)
labels.push(`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`)
}
const label = t('generalItems.RHRLabel') + " (" + t('generalItems.unitsBpm') + ")"
const label = t('generalItems.RHRLabel') + ' (' + t('generalItems.unitsBpm') + ')'
const datasets = [
{
@@ -235,4 +232,4 @@ onUnmounted(() => {
width: 100%;
/* Ensures the canvas stretches across the available width */
}
</style>
</style>

View File

@@ -6,7 +6,8 @@
<span>{{ userHealthSleep.resting_heart_rate }} {{ $t('generalItems.unitsBpm') }}</span>
</div>
<span>
{{ $t('healthStepsListComponent.labelDate') }}: {{ formatDateShort(userHealthSleep.date) }}
{{ $t('healthStepsListComponent.labelDate') }}:
{{ formatDateShort(userHealthSleep.date) }}
</span>
</div>
</div>

View File

@@ -1,38 +1,61 @@
<template>
<div class="col-lg-3 col-md-12">
<ul class="nav nav-pills flex-column mb-auto bg-body-tertiary rounded p-3 shadow-sm" id="sidebarNav">
<ul
class="nav nav-pills flex-column mb-auto bg-body-tertiary rounded p-3 shadow-sm"
id="sidebarNav"
>
<li class="nav-item">
<a href="#" class="nav-link link-body-emphasis" :class="{ active: activeSection === 'dashboard' }"
@click.prevent="changeActive('dashboard')">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'dashboard' }"
@click.prevent="changeActive('dashboard')"
>
<font-awesome-icon :icon="['fas', 'dashboard']" />
<span class="ms-1">{{ $t('healthSideBarComponent.dashboardSection') }}</span>
</a>
</li>
<hr />
<li class="nav-item">
<a href="#" class="nav-link link-body-emphasis" :class="{ active: activeSection === 'sleep' }"
@click.prevent="changeActive('sleep')">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'sleep' }"
@click.prevent="changeActive('sleep')"
>
<font-awesome-icon :icon="['fas', 'bed']" />
<span class="ms-1">{{ $t('healthSideBarComponent.sleepSection') }}</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link link-body-emphasis" :class="{ active: activeSection === 'rhr' }"
@click.prevent="changeActive('rhr')">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'rhr' }"
@click.prevent="changeActive('rhr')"
>
<font-awesome-icon :icon="['fas', 'heart-pulse']" />
<span class="ms-1">{{ $t('healthSideBarComponent.RHRSection') }}</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link link-body-emphasis" :class="{ active: activeSection === 'steps' }"
@click.prevent="changeActive('steps')">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'steps' }"
@click.prevent="changeActive('steps')"
>
<font-awesome-icon :icon="['fas', 'shoe-prints']" />
<span class="ms-1">{{ $t('healthSideBarComponent.stepsSection') }}</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link link-body-emphasis" :class="{ active: activeSection === 'weight' }"
@click.prevent="changeActive('weight')">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'weight' }"
@click.prevent="changeActive('weight')"
>
<font-awesome-icon :icon="['fas', 'weight']" />
<span class="ms-2">{{ $t('healthSideBarComponent.weightSection') }}</span>
</a>

View File

@@ -4,36 +4,56 @@
<div v-else>
<!-- add sleep button -->
<div class="d-flex">
<a class="w-100 btn btn-primary shadow-sm me-1 disabled" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addSleepModal">{{ t('healthSleepZoneComponent.buttonAddSleep') }}</a>
<a class="w-100 btn btn-primary shadow-sm ms-1" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addSleepTargetModal">{{ $t('healthSleepZoneComponent.buttonSleepTarget') }}</a>
<a
class="w-100 btn btn-primary shadow-sm me-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addSleepModal"
>{{ t('healthSleepZoneComponent.buttonAddSleep') }}</a
>
<a
class="w-100 btn btn-primary shadow-sm ms-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addSleepTargetModal"
>{{ $t('healthSleepZoneComponent.buttonSleepTarget') }}</a
>
</div>
<!--<HealthStepsAddEditModalComponent
<HealthSleepAddEditModalComponent
:action="'add'"
@isLoadingNewSteps="updateIsLoadingNewSteps"
@createdSteps="updateStepsListAdded"
/>-->
@isLoadingNewSleep="updateIsLoadingNewSleep"
@createdSleep="updateSleepListAdded"
/>
<ModalComponentHoursMinutesInput modalId="addSleepTargetModal"
<ModalComponentHoursMinutesInput
modalId="addSleepTargetModal"
:title="t('healthSleepZoneComponent.buttonSleepTarget')"
:hoursFieldLabel="t('healthSleepZoneComponent.modalSleepTargetHoursLabel')"
:minutesFieldLabel="t('healthSleepZoneComponent.modalSleepTargetMinutesLabel')" actionButtonType="success"
:minutesFieldLabel="t('healthSleepZoneComponent.modalSleepTargetMinutesLabel')"
actionButtonType="success"
:actionButtonText="t('generalItems.buttonSubmit')"
:secondsDefaultValue="props.userHealthTargets?.sleep || 28800" @fieldsToEmitAction="submitSetSleepTarget" />
:secondsDefaultValue="props.userHealthTargets?.sleep || 28800"
@fieldsToEmitAction="submitSetSleepTarget"
/>
<!-- Checking if userHealthSleepPagination is loaded and has length -->
<div v-if="userHealthSleepPagination && userHealthSleepPagination.length"
class="mt-3 p-3 bg-body-tertiary rounded shadow-sm">
<div
v-if="userHealthSleepPagination && userHealthSleepPagination.length"
class="mt-3 p-3 bg-body-tertiary rounded shadow-sm"
>
<!-- show graph -->
<HealthSleepBarChartComponent :userHealthTargets="userHealthTargets" :userHealthSleep="userHealthSleep"
:isLoading="isLoading" />
<HealthSleepBarChartComponent
:userHealthTargets="userHealthTargets"
:userHealthSleep="userHealthSleep"
:isLoading="isLoading"
/>
<br />
<p>
{{ $t('healthSleepZoneComponent.labelNumberOfHealthSleep1')
}}{{ userHealthSleep.length
{{ $t('healthSleepZoneComponent.labelNumberOfHealthSleep1') }}{{ userHealthSleep.length
}}{{ $t('healthSleepZoneComponent.labelNumberOfHealthSleep2')
}}{{ userHealthSleepPagination.length
}}{{ $t('healthSleepZoneComponent.labelNumberOfHealthSleep3') }}
@@ -47,17 +67,28 @@
</ul>
<!-- list zone -->
<ul class="my-3 list-group list-group-flush" v-for="userHealthSleep in userHealthSleepPagination"
:key="userHealthSleep.id" :data="userHealthSleep">
<ul
class="my-3 list-group list-group-flush"
v-for="userHealthSleep in userHealthSleepPagination"
:key="userHealthSleep.id"
:data="userHealthSleep"
>
<!--<HealthSleepTimelineChartComponent
:data="userHealthSleep.sleep_stages"
/>-->
<HealthSleepListComponent :userHealthSleep="userHealthSleep" @deletedSleep="updateSleepListDeleted"
@editedSleep="updateSleepListEdited" />
<HealthSleepListComponent
:userHealthSleep="userHealthSleep"
@deletedSleep="updateSleepListDeleted"
@editedSleep="updateSleepListEdited"
/>
</ul>
<!-- pagination area -->
<PaginationComponent :totalPages="totalPages" :pageNumber="pageNumber" @pageNumberChanged="setPageNumber" />
<PaginationComponent
:totalPages="totalPages"
:pageNumber="pageNumber"
@pageNumberChanged="setPageNumber"
/>
</div>
<!-- Displaying a message or component when there are no weight measurements -->
<div v-else class="mt-3">
@@ -72,6 +103,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HealthSleepAddEditModalComponent from './HealthSleepZone/HealthSleepAddEditModalComponent.vue'
import ModalComponentHoursMinutesInput from '../Modals/ModalComponentHoursMinutesInput.vue'
import HealthSleepBarChartComponent from './HealthSleepZone/HealthSleepBarChartComponent.vue'
import HealthSleepListComponent from './HealthSleepZone/HealthSleepListComponent.vue'
@@ -106,11 +138,25 @@ const props = defineProps({
}
})
const emit = defineEmits(['deletedSleep', 'editedSleep', 'pageNumberChanged', 'setSleepTarget'])
const emit = defineEmits([
'createdSleep',
'deletedSleep',
'editedSleep',
'pageNumberChanged',
'setSleepTarget'
])
const { t } = useI18n()
const isLoadingNewSleep = ref(false)
function updateIsLoadingNewSleep(isLoadingNewSleepNewValue) {
isLoadingNewSleep.value = isLoadingNewSleepNewValue
}
function updateSleepListAdded(createdSleep) {
emit('createdSleep', createdSleep)
}
function updateSleepListDeleted(deletedSleep) {
emit('deletedSleep', deletedSleep)
}
@@ -126,4 +172,4 @@ function setPageNumber(page) {
function submitSetSleepTarget(sleepTarget) {
emit('setSleepTarget', sleepTarget)
}
</script>
</script>

View File

@@ -0,0 +1,691 @@
<template>
<!-- Modal add/edit sleep -->
<div class="modal fade" :id="action === 'add' ? 'addSleepModal' : action === 'edit' ? editSleepId : ''"
tabindex="-1" :aria-labelledby="action === 'add' ? 'addSleepModal' : action === 'edit' ? editSleepId : ''"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addSleepModal" v-if="action === 'add'">
{{ $t('healthSleepAddEditModalComponent.addSleepModalTitle') }}
</h1>
<h1 class="modal-title fs-5" :id="editSleepId" v-else>
{{ $t('healthSleepAddEditModalComponent.editSleepModalTitle') }}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<!-- Date field -->
<div class="mb-3">
<label for="sleepDate" class="form-label">
<b>* {{ $t('healthSleepAddEditModalComponent.dateLabel') }}</b>
</label>
<input id="sleepDate" class="form-control" type="date" v-model="formData.date" required />
</div>
<!-- Sleep times section -->
<div class="row mb-3">
<div class="col-md-6">
<label for="sleepStartTime" class="form-label">
<b>* {{ $t('healthSleepAddEditModalComponent.sleepStartTimeLabel') }}</b>
</label>
<input id="sleepStartTime" class="form-control" type="datetime-local"
v-model="formData.sleepStartTime" required />
</div>
<div class="col-md-6">
<label for="sleepEndTime" class="form-label">
<b>* {{ $t('healthSleepAddEditModalComponent.sleepEndTimeLabel') }}</b>
</label>
<input id="sleepEndTime" class="form-control" type="datetime-local"
v-model="formData.sleepEndTime" required />
</div>
</div>
<!-- Sleep durations section -->
<!-- Total Sleep -->
<div class="mb-3">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.totalSleepLabel') }}</b>
</label>
<div class="input-group">
<input id="totalSleepHours" v-model.number="formData.totalSleepHours"
class="form-control" type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="totalSleepMinutes" v-model.number="formData.totalSleepMinutes"
class="form-control" type="number" :placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
<!-- Deep Sleep -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.deepSleepLabel') }}</b>
</label>
<div class="input-group">
<input id="deepSleepHours" v-model.number="formData.deepSleepHours"
class="form-control" type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="deepSleepMinutes" v-model.number="formData.deepSleepMinutes"
class="form-control" type="number"
:placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.lightSleepLabel') }}</b>
</label>
<div class="input-group">
<input id="lightSleepHours" v-model.number="formData.lightSleepHours"
class="form-control" type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="lightSleepMinutes" v-model.number="formData.lightSleepMinutes"
class="form-control" type="number"
:placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
</div>
<!-- REM and Awake Sleep -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.remSleepLabel') }}</b>
</label>
<div class="input-group">
<input id="remSleepHours" v-model.number="formData.remSleepHours"
class="form-control" type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="remSleepMinutes" v-model.number="formData.remSleepMinutes"
class="form-control" type="number"
:placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.awakeSleepLabel') }}</b>
</label>
<div class="input-group">
<input id="awakeSleepHours" v-model.number="formData.awakeSleepHours"
class="form-control" type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="awakeSleepMinutes" v-model.number="formData.awakeSleepMinutes"
class="form-control" type="number"
:placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
</div>
<!-- Nap Time -->
<div class="mb-3">
<label class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.napTimeLabel') }}</b>
</label>
<div class="input-group">
<input id="napTimeHours" v-model.number="formData.napTimeHours" class="form-control"
type="number" :placeholder="$t('generalItems.labelHours')"
:aria-label="$t('generalItems.labelHours')" />
<span class="input-group-text">{{ $t('generalItems.labelHoursShort') }}</span>
<input id="napTimeMinutes" v-model.number="formData.napTimeMinutes" class="form-control"
type="number" :placeholder="$t('generalItems.labelMinutes')"
:aria-label="$t('generalItems.labelMinutes')" />
<span class="input-group-text">{{ $t('generalItems.labelMinutesShort') }}</span>
</div>
</div>
<!-- Heart rate section -->
<div class="row mb-3">
<div class="col-md-4">
<label for="avgHeartRate" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.avgHeartRateLabel') }}</b>
</label>
<input id="avgHeartRate"
:placeholder="$t('healthSleepAddEditModalComponent.avgHeartRateLabel')"
class="form-control" type="number" step="0.01"
v-model.number="formData.avgHeartRate" />
</div>
<div class="col-md-4">
<label for="minHeartRate" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.minHeartRateLabel') }}</b>
</label>
<input id="minHeartRate"
:placeholder="$t('healthSleepAddEditModalComponent.minHeartRateLabel')"
class="form-control" type="number" v-model.number="formData.minHeartRate" />
</div>
<div class="col-md-4">
<label for="maxHeartRate" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.maxHeartRateLabel') }}</b>
</label>
<input id="maxHeartRate"
:placeholder="$t('healthSleepAddEditModalComponent.maxHeartRateLabel')"
class="form-control" type="number" v-model.number="formData.maxHeartRate" />
</div>
</div>
<!-- SpO2 section -->
<div class="row mb-3">
<div class="col-md-4">
<label for="avgSpo2" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.avgSpo2Label') }}</b>
</label>
<input id="avgSpo2" :placeholder="$t('healthSleepAddEditModalComponent.avgSpo2Label')"
class="form-control" type="number" step="0.01" v-model.number="formData.avgSpo2" />
</div>
<div class="col-md-4">
<label for="lowestSpo2" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.lowestSpo2Label') }}</b>
</label>
<input id="lowestSpo2"
:placeholder="$t('healthSleepAddEditModalComponent.lowestSpo2Label')"
class="form-control" type="number" v-model.number="formData.lowestSpo2" />
</div>
<div class="col-md-4">
<label for="highestSpo2" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.highestSpo2Label') }}</b>
</label>
<input id="highestSpo2"
:placeholder="$t('healthSleepAddEditModalComponent.highestSpo2Label')"
class="form-control" type="number" v-model.number="formData.highestSpo2" />
</div>
</div>
<!-- Sleep scores section -->
<div class="row mb-3">
<div class="col-md-4">
<label for="sleepScoreOverall" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.sleepScoreOverallLabel') }}</b>
</label>
<input id="sleepScoreOverall" class="form-control" type="number"
v-model.number="formData.sleepScoreOverall" />
</div>
<div class="col-md-4">
<label for="awakeCount" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.awakeCountLabel') }}</b>
</label>
<input id="awakeCount" class="form-control" type="number"
v-model.number="formData.awakeCount" />
</div>
<div class="col-md-4">
<label for="restlessMomentsCount" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.restlessMomentsCountLabel') }}</b>
</label>
<input id="restlessMomentsCount" class="form-control" type="number"
v-model.number="formData.restlessMomentsCount" />
</div>
</div>
<!-- Sleep stages section (dynamic) -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">
<b>{{ $t('healthSleepAddEditModalComponent.sleepStagesLabel') }}</b>
</label>
<button type="button" class="btn btn-sm btn-primary" @click="addSleepStage"
aria-label="Add sleep stage">
<i class="bi bi-plus-circle"></i>
{{ $t('healthSleepAddEditModalComponent.addStageButton') }}
</button>
</div>
<div v-for="(stage, index) in formData.sleepStages" :key="index" class="card mb-2">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">
{{ $t('healthSleepAddEditModalComponent.stageLabel') }} {{ index + 1 }}
</h6>
<button type="button" class="btn btn-sm btn-danger"
@click="removeSleepStage(index)" :aria-label="`Remove stage ${index + 1}`">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="row">
<div class="col-md-3">
<label :for="`stageType${index}`" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.stageTypeLabel') }}</b>
</label>
<select :id="`stageType${index}`" class="form-select"
v-model.number="stage.stageType">
<option :value="0">
{{ $t('healthSleepAddEditModalComponent.stageTypeDeep') }}
</option>
<option :value="1">
{{ $t('healthSleepAddEditModalComponent.stageTypeLight') }}
</option>
<option :value="2">
{{ $t('healthSleepAddEditModalComponent.stageTypeRem') }}
</option>
<option :value="3">
{{ $t('healthSleepAddEditModalComponent.stageTypeAwake') }}
</option>
</select>
</div>
<div class="col-md-3">
<label :for="`stageStartTime${index}`" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.stageStartTimeLabel') }}</b>
</label>
<input :id="`stageStartTime${index}`" class="form-control"
type="datetime-local" v-model="stage.startTimeGmt" />
</div>
<div class="col-md-3">
<label :for="`stageEndTime${index}`" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.stageEndTimeLabel') }}</b>
</label>
<input :id="`stageEndTime${index}`" class="form-control"
type="datetime-local" v-model="stage.endTimeGmt" />
</div>
<div class="col-md-3">
<label :for="`stageDuration${index}`" class="form-label">
<b>{{ $t('healthSleepAddEditModalComponent.stageDurationLabel') }}</b>
</label>
<input :id="`stageDuration${index}`" class="form-control" type="number"
v-model.number="stage.durationSeconds" placeholder="Seconds" />
</div>
</div>
</div>
</div>
<div v-if="formData.sleepStages.length === 0" class="alert alert-info" role="alert">
{{ $t('healthSleepAddEditModalComponent.noStagesMessage') }}
</div>
</div>
<p class="text-muted">* {{ $t('generalItems.requiredField') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t('generalItems.buttonClose') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-if="action === 'add'">
{{ $t('healthSleepAddEditModalComponent.addSleepModalTitle') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-else>
{{ $t('healthSleepAddEditModalComponent.editSleepModalTitle') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'
import { health_sleep } from '@/services/health_sleepService'
import { returnHoursMinutesFromSeconds, returnSecondsFromHoursMinutes } from '@/utils/dateTimeUtils'
interface SleepStage {
stageType: number | null
startTimeGmt: string | null
endTimeGmt: string | null
durationSeconds: number | null
}
interface SleepFormData {
date: string
sleepStartTime: string
sleepEndTime: string
totalSleepHours: number | null
totalSleepMinutes: number | null
napTimeHours: number | null
napTimeMinutes: number | null
deepSleepHours: number | null
deepSleepMinutes: number | null
lightSleepHours: number | null
lightSleepMinutes: number | null
remSleepHours: number | null
remSleepMinutes: number | null
awakeSleepHours: number | null
awakeSleepMinutes: number | null
avgHeartRate: number | null
minHeartRate: number | null
maxHeartRate: number | null
avgSpo2: number | null
lowestSpo2: number | null
highestSpo2: number | null
sleepScoreOverall: number | null
awakeCount: number | null
restlessMomentsCount: number | null
sleepStages: SleepStage[]
}
interface UserHealthSleep {
id: number
user_id: number
date: string
sleep_start_time_gmt: string
sleep_end_time_gmt: string
sleep_start_time_local?: string
sleep_end_time_local?: string
total_sleep_seconds?: number
nap_time_seconds?: number
deep_sleep_seconds?: number
light_sleep_seconds?: number
rem_sleep_seconds?: number
awake_sleep_seconds?: number
avg_heart_rate?: number
min_heart_rate?: number
max_heart_rate?: number
avg_spo2?: number
lowest_spo2?: number
highest_spo2?: number
sleep_score_overall?: number
awake_count?: number
restless_moments_count?: number
sleep_stages?: Array<{
stage_type: number | null
start_time_gmt: string | null
end_time_gmt: string | null
duration_seconds: number | null
}>
}
const props = defineProps<{
action: 'add' | 'edit'
userHealthSleep?: UserHealthSleep
}>()
const emit = defineEmits<{
isLoadingNewSleep: [value: boolean]
createdSleep: [sleep: UserHealthSleep]
editedSleep: [sleep: UserHealthSleep]
}>()
const { t } = useI18n()
const formData = ref<SleepFormData>({
date: new Date().toISOString().split('T')[0] as string,
sleepStartTime: '',
sleepEndTime: '',
totalSleepHours: null,
totalSleepMinutes: null,
napTimeHours: null,
napTimeMinutes: null,
deepSleepHours: null,
deepSleepMinutes: null,
lightSleepHours: null,
lightSleepMinutes: null,
remSleepHours: null,
remSleepMinutes: null,
awakeSleepHours: null,
awakeSleepMinutes: null,
avgHeartRate: null,
minHeartRate: null,
maxHeartRate: null,
avgSpo2: null,
lowestSpo2: null,
highestSpo2: null,
sleepScoreOverall: null,
awakeCount: null,
restlessMomentsCount: null,
sleepStages: []
})
const editSleepId = ref('')
onMounted(() => {
if (props.userHealthSleep) {
const totalSleep = returnHoursMinutesFromSeconds(props.userHealthSleep.total_sleep_seconds ?? 0)
const napTime = returnHoursMinutesFromSeconds(props.userHealthSleep.nap_time_seconds ?? 0)
const deepSleep = returnHoursMinutesFromSeconds(props.userHealthSleep.deep_sleep_seconds ?? 0)
const lightSleep = returnHoursMinutesFromSeconds(props.userHealthSleep.light_sleep_seconds ?? 0)
const remSleep = returnHoursMinutesFromSeconds(props.userHealthSleep.rem_sleep_seconds ?? 0)
const awakeSleep = returnHoursMinutesFromSeconds(props.userHealthSleep.awake_sleep_seconds ?? 0)
formData.value = {
date: props.userHealthSleep.date,
sleepStartTime: formatDateTimeForInput(props.userHealthSleep.sleep_start_time_gmt),
sleepEndTime: formatDateTimeForInput(props.userHealthSleep.sleep_end_time_gmt),
totalSleepHours: totalSleep.hours,
totalSleepMinutes: totalSleep.minutes,
napTimeHours: napTime.hours,
napTimeMinutes: napTime.minutes,
deepSleepHours: deepSleep.hours,
deepSleepMinutes: deepSleep.minutes,
lightSleepHours: lightSleep.hours,
lightSleepMinutes: lightSleep.minutes,
remSleepHours: remSleep.hours,
remSleepMinutes: remSleep.minutes,
awakeSleepHours: awakeSleep.hours,
awakeSleepMinutes: awakeSleep.minutes,
avgHeartRate: props.userHealthSleep.avg_heart_rate ?? null,
minHeartRate: props.userHealthSleep.min_heart_rate ?? null,
maxHeartRate: props.userHealthSleep.max_heart_rate ?? null,
avgSpo2: props.userHealthSleep.avg_spo2 ?? null,
lowestSpo2: props.userHealthSleep.lowest_spo2 ?? null,
highestSpo2: props.userHealthSleep.highest_spo2 ?? null,
sleepScoreOverall: props.userHealthSleep.sleep_score_overall ?? null,
awakeCount: props.userHealthSleep.awake_count ?? null,
restlessMomentsCount: props.userHealthSleep.restless_moments_count ?? null,
sleepStages:
props.userHealthSleep.sleep_stages?.map((stage) => ({
stageType: stage.stage_type,
startTimeGmt: stage.start_time_gmt ? formatDateTimeForInput(stage.start_time_gmt) : null,
endTimeGmt: stage.end_time_gmt ? formatDateTimeForInput(stage.end_time_gmt) : null,
durationSeconds: stage.duration_seconds
})) ?? []
}
editSleepId.value = `editSleepId${props.userHealthSleep.id}`
}
})
/**
* Formats ISO datetime string for datetime-local input.
*
* @param isoString - ISO 8601 datetime string.
* @returns Formatted string for datetime-local input (YYYY-MM-DDTHH:mm).
*/
function formatDateTimeForInput(isoString: string): string {
return isoString.slice(0, 16)
}
/**
* Adds a new empty sleep stage to the form.
*/
function addSleepStage(): void {
formData.value.sleepStages.push({
stageType: 1,
startTimeGmt: null,
endTimeGmt: null,
durationSeconds: null
})
}
/**
* Removes a sleep stage at the specified index.
*
* @param index - Index of the sleep stage to remove.
*/
function removeSleepStage(index: number): void {
formData.value.sleepStages.splice(index, 1)
}
/**
* Submits the form to add a new sleep entry.
*
* @throws Error if API request fails.
*/
async function submitAddSleep(): Promise<void> {
emit('isLoadingNewSleep', true)
try {
const data = {
date: formData.value.date,
sleep_start_time_gmt: formData.value.sleepStartTime,
sleep_end_time_gmt: formData.value.sleepEndTime,
total_sleep_seconds:
formData.value.totalSleepHours !== null && formData.value.totalSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.totalSleepHours,
formData.value.totalSleepMinutes
)
: null,
nap_time_seconds:
formData.value.napTimeHours !== null && formData.value.napTimeMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.napTimeHours,
formData.value.napTimeMinutes
)
: null,
deep_sleep_seconds:
formData.value.deepSleepHours !== null && formData.value.deepSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.deepSleepHours,
formData.value.deepSleepMinutes
)
: null,
light_sleep_seconds:
formData.value.lightSleepHours !== null && formData.value.lightSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.lightSleepHours,
formData.value.lightSleepMinutes
)
: null,
rem_sleep_seconds:
formData.value.remSleepHours !== null && formData.value.remSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.remSleepHours,
formData.value.remSleepMinutes
)
: null,
awake_sleep_seconds:
formData.value.awakeSleepHours !== null && formData.value.awakeSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.awakeSleepHours,
formData.value.awakeSleepMinutes
)
: null,
avg_heart_rate: formData.value.avgHeartRate,
min_heart_rate: formData.value.minHeartRate,
max_heart_rate: formData.value.maxHeartRate,
avg_spo2: formData.value.avgSpo2,
lowest_spo2: formData.value.lowestSpo2,
highest_spo2: formData.value.highestSpo2,
sleep_score_overall: formData.value.sleepScoreOverall,
awake_count: formData.value.awakeCount,
restless_moments_count: formData.value.restlessMomentsCount,
sleep_stages: formData.value.sleepStages.map((stage) => ({
stage_type: stage.stageType,
start_time_gmt: stage.startTimeGmt,
end_time_gmt: stage.endTimeGmt,
duration_seconds: stage.durationSeconds
}))
}
const createdSleep = await health_sleep.createHealthSleep(data)
emit('isLoadingNewSleep', false)
emit('createdSleep', createdSleep)
push.success(t('healthSleepAddEditModalComponent.successAddSleep'))
} catch (error) {
emit('isLoadingNewSleep', false)
push.error(
`${t('healthSleepAddEditModalComponent.errorAddSleep')} - ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* Submits the form to edit an existing sleep entry.
*/
function submitEditSleep(): void {
if (!props.userHealthSleep) return
const editedData = {
id: props.userHealthSleep.id,
user_id: props.userHealthSleep.user_id,
date: formData.value.date,
sleep_start_time_gmt: formData.value.sleepStartTime,
sleep_end_time_gmt: formData.value.sleepEndTime,
total_sleep_seconds:
formData.value.totalSleepHours !== null && formData.value.totalSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.totalSleepHours,
formData.value.totalSleepMinutes
)
: null,
nap_time_seconds:
formData.value.napTimeHours !== null && formData.value.napTimeMinutes !== null
? returnSecondsFromHoursMinutes(formData.value.napTimeHours, formData.value.napTimeMinutes)
: null,
deep_sleep_seconds:
formData.value.deepSleepHours !== null && formData.value.deepSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.deepSleepHours,
formData.value.deepSleepMinutes
)
: null,
light_sleep_seconds:
formData.value.lightSleepHours !== null && formData.value.lightSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.lightSleepHours,
formData.value.lightSleepMinutes
)
: null,
rem_sleep_seconds:
formData.value.remSleepHours !== null && formData.value.remSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.remSleepHours,
formData.value.remSleepMinutes
)
: null,
awake_sleep_seconds:
formData.value.awakeSleepHours !== null && formData.value.awakeSleepMinutes !== null
? returnSecondsFromHoursMinutes(
formData.value.awakeSleepHours,
formData.value.awakeSleepMinutes
)
: null,
avg_heart_rate: formData.value.avgHeartRate,
min_heart_rate: formData.value.minHeartRate,
max_heart_rate: formData.value.maxHeartRate,
avg_spo2: formData.value.avgSpo2,
lowest_spo2: formData.value.lowestSpo2,
highest_spo2: formData.value.highestSpo2,
sleep_score_overall: formData.value.sleepScoreOverall,
awake_count: formData.value.awakeCount,
restless_moments_count: formData.value.restlessMomentsCount,
sleep_stages: formData.value.sleepStages.map((stage) => ({
stage_type: stage.stageType,
start_time_gmt: stage.startTimeGmt,
end_time_gmt: stage.endTimeGmt,
duration_seconds: stage.durationSeconds
}))
}
emit('editedSleep', editedData as UserHealthSleep)
}
/**
* Handles form submission for both add and edit actions.
*/
function handleSubmit(): void {
if (props.action === 'add') {
submitAddSleep()
} else {
submitEditSleep()
}
}
</script>

View File

@@ -73,14 +73,12 @@ const chartData = computed(() => {
const data = []
const labels = []
for (const healthSleep of sortedSleep) {
data.push(healthSleep.total_sleep_seconds)
const createdAt = new Date(healthSleep.date)
labels.push(
`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`
)
labels.push(`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`)
}
const datasets = [
@@ -143,7 +141,7 @@ onMounted(() => {
beginAtZero: true,
ticks: {
stepSize: 3600, // 1 hour in seconds
callback: function(value) {
callback: function (value) {
return formatDurationHHmm(value)
}
},
@@ -181,7 +179,7 @@ onMounted(() => {
if (value === null || value === undefined) {
return `${label}: N/A`
}
return `${label}: ${formatDuration(value)}`
}
}

View File

@@ -7,7 +7,8 @@
<span>{{ formatDuration(userHealthSleep.total_sleep_seconds) }}</span>
</div>
<span>
{{ $t('healthSleepListComponent.labelDate') }}: {{ formatDateShort(userHealthSleep.date) }}
{{ $t('healthSleepListComponent.labelDate') }}:
{{ formatDateShort(userHealthSleep.date) }}
</span>
</div>
</div>
@@ -24,6 +25,14 @@
<img :src="INTEGRATION_LOGOS.garminConnectApp" alt="Garmin Connect logo" height="22" />
</span>
<!-- edit weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editSleepId${userHealthSleep.id}`"><font-awesome-icon
:icon="['fas', 'fa-pen-to-square']" /></a>
<HealthSleepAddEditModalComponent :action="'edit'" :userHealthSleep="userHealthSleep"
@editedSleep="updateSleepListEdited" />
<!-- delete weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#deleteSleepModal${userHealthSleep.id}`"><font-awesome-icon
@@ -51,6 +60,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import HealthSleepAddEditModalComponent from './HealthSleepAddEditModalComponent.vue'
import HealthSleepListTabsComponent from './HealthSleepListTabsComponent.vue'
import HealthSleepTimelineChartComponent from './HealthSleepTimelineChartComponent.vue'
import ModalComponent from '@/components/Modals/ModalComponent.vue'
@@ -110,4 +120,4 @@ onMounted(async () => {
})
}
})
</script>
</script>

View File

@@ -2,18 +2,30 @@
<!-- Bootstrap Tabs Navigation -->
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sleep-score-tab-${userHealthSleep.id}`" data-bs-toggle="tab"
:data-bs-target="`#sleep-score-${userHealthSleep.id}`" type="button" role="tab"
:aria-controls="`sleep-score-${userHealthSleep.id}`" aria-selected="true">
<button
class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sleep-score-tab-${userHealthSleep.id}`"
data-bs-toggle="tab"
:data-bs-target="`#sleep-score-${userHealthSleep.id}`"
type="button"
role="tab"
:aria-controls="`sleep-score-${userHealthSleep.id}`"
aria-selected="true"
>
{{ $t('healthSleepListTabsComponent.sleepScoreLabel') }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sleep-details-tab-${userHealthSleep.id}`" data-bs-toggle="tab"
:data-bs-target="`#sleep-details-${userHealthSleep.id}`" type="button" role="tab"
:aria-controls="`sleep-details-${userHealthSleep.id}`" aria-selected="false">
<button
class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sleep-details-tab-${userHealthSleep.id}`"
data-bs-toggle="tab"
:data-bs-target="`#sleep-details-${userHealthSleep.id}`"
type="button"
role="tab"
:aria-controls="`sleep-details-${userHealthSleep.id}`"
aria-selected="false"
>
{{ $t('healthSleepListTabsComponent.sleepDetailsLabel') }}
</button>
</li>
@@ -22,8 +34,12 @@
<!-- Tab Content -->
<div class="tab-content mt-3">
<!-- Score tab -->
<div class="tab-pane fade show active" :id="`sleep-score-${userHealthSleep.id}`" role="tabpanel"
:aria-labelledby="`sleep-score-tab-${userHealthSleep.id}`">
<div
class="tab-pane fade show active"
:id="`sleep-score-${userHealthSleep.id}`"
role="tabpanel"
:aria-labelledby="`sleep-score-tab-${userHealthSleep.id}`"
>
<!-- Sleep summary -->
<section class="pb-3 mb-3 border-bottom">
<h6 class="fw-semibold mb-2">
@@ -36,7 +52,9 @@
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.scoreLabel') }}:
</span>
<span v-if="userHealthSleep.sleep_score_overall">{{ userHealthSleep.sleep_score_overall }}</span>
<span v-if="userHealthSleep.sleep_score_overall">{{
userHealthSleep.sleep_score_overall
}}</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- sleep_score_quality -->
@@ -45,7 +63,8 @@
{{ $t('healthSleepListTabsComponent.qualityLabel') }}:
</span>
<span v-if="userHealthSleep.sleep_score_quality">{{
$t(getScoreStatusI18nKey(userHealthSleep.sleep_score_quality)) }}</span>
$t(getScoreStatusI18nKey(userHealthSleep.sleep_score_quality))
}}</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
</div>
@@ -57,15 +76,16 @@
{{ $t('healthSleepListTabsComponent.durationLabel') }}:
</span>
<span v-if="userHealthSleep.sleep_score_duration">{{
$t(getScoreStatusI18nKey(userHealthSleep.sleep_score_duration)) }}</span>
$t(getScoreStatusI18nKey(userHealthSleep.sleep_score_duration))
}}</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- hrv_status -->
<p class="mb-0">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.HRVLabel') }}:
</span>
<span v-if="userHealthSleep.hrv_status">{{ $t(getHrvStatusI18nKey(userHealthSleep.hrv_status)) }}</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.HRVLabel') }}: </span>
<span v-if="userHealthSleep.hrv_status">{{
$t(getHrvStatusI18nKey(userHealthSleep.hrv_status))
}}</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
</div>
@@ -81,9 +101,7 @@
<div class="col-12 col-md-6">
<!-- deep_sleep_seconds -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.deepLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.deepLabel') }}: </span>
<span v-if="userHealthSleep.deep_sleep_seconds">
{{ formatDuration(userHealthSleep.deep_sleep_seconds) }}
-
@@ -93,9 +111,7 @@
</p>
<!-- rem_sleep_seconds-->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.REMLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.REMLabel') }}: </span>
<span v-if="userHealthSleep.rem_sleep_seconds">
{{ formatDuration(userHealthSleep.rem_sleep_seconds) }}
-
@@ -136,8 +152,12 @@
</div>
<!-- Sleep Details Tab -->
<div class="tab-pane fade" :id="`sleep-details-${userHealthSleep.id}`" role="tabpanel"
:aria-labelledby="`sleep-details-tab-${userHealthSleep.id}`">
<div
class="tab-pane fade"
:id="`sleep-details-${userHealthSleep.id}`"
role="tabpanel"
:aria-labelledby="`sleep-details-tab-${userHealthSleep.id}`"
>
<!-- Heart Rate -->
<section class="pb-3 mb-3 border-bottom">
<div class="row">
@@ -158,7 +178,8 @@
{{ $t('healthSleepListTabsComponent.avgSkinTempDeviationLabel') }}:
</span>
<span v-if="userHealthSleep.avg_skin_temp_deviation">
{{ parseFloat(userHealthSleep.avg_skin_temp_deviation) }} {{ $t('generalItems.unitsCelsius') }}
{{ parseFloat(userHealthSleep.avg_skin_temp_deviation) }}
{{ $t('generalItems.unitsCelsius') }}
</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
@@ -186,9 +207,7 @@
<div class="col-12 col-md-6">
<!-- avg_heart_rate -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.avgLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.avgLabel') }}: </span>
<span v-if="userHealthSleep.avg_heart_rate">
{{ Number(userHealthSleep.avg_heart_rate) }} {{ $t('generalItems.unitsBpm') }}
</span>
@@ -196,9 +215,7 @@
</p>
<!-- max_heart_rate -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.maxLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.maxLabel') }}: </span>
<span v-if="userHealthSleep.max_heart_rate">
{{ Number(userHealthSleep.max_heart_rate) }} {{ $t('generalItems.unitsBrpm') }}
</span>
@@ -208,9 +225,7 @@
<div class="col-12 col-md-6">
<!-- min_heart_rate -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.minLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.minLabel') }}: </span>
<span v-if="userHealthSleep.min_heart_rate">
{{ Number(userHealthSleep.min_heart_rate) }} {{ $t('generalItems.unitsBrpm') }}
</span>
@@ -229,19 +244,13 @@
<div class="col-12 col-md-6">
<!-- avg_spo2 -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.avgLabel') }}:
</span>
<span v-if="userHealthSleep.avg_spo2">
{{ Number(userHealthSleep.avg_spo2) }}%
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.avgLabel') }}: </span>
<span v-if="userHealthSleep.avg_spo2"> {{ Number(userHealthSleep.avg_spo2) }}% </span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- highest_spo2 -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.maxLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.maxLabel') }}: </span>
<span v-if="userHealthSleep.highest_spo2">
{{ Number(userHealthSleep.highest_spo2) }}%
</span>
@@ -251,9 +260,7 @@
<div class="col-12 col-md-6">
<!-- lowest_spo2 -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.minLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.minLabel') }}: </span>
<span v-if="userHealthSleep.lowest_spo2">
{{ Number(userHealthSleep.lowest_spo2) }}%
</span>
@@ -272,9 +279,7 @@
<div class="col-12 col-md-6">
<!-- avg_respiration -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.avgLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.avgLabel') }}: </span>
<span v-if="userHealthSleep.avg_respiration">
{{ Number(userHealthSleep.avg_respiration) }} {{ $t('generalItems.unitsBrpm') }}
</span>
@@ -282,9 +287,7 @@
</p>
<!-- highest_respiration -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.maxLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.maxLabel') }}: </span>
<span v-if="userHealthSleep.highest_respiration">
{{ Number(userHealthSleep.highest_respiration) }} {{ $t('generalItems.unitsBrpm') }}
</span>
@@ -294,9 +297,7 @@
<div class="col-12 col-md-6">
<!-- lowest_respiration -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthSleepListTabsComponent.minLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthSleepListTabsComponent.minLabel') }}: </span>
<span v-if="userHealthSleep.lowest_respiration">
{{ Number(userHealthSleep.lowest_respiration) }} {{ $t('generalItems.unitsBrpm') }}
</span>
@@ -319,5 +320,4 @@ const props = defineProps({
required: true
}
})
</script>
</script>

View File

@@ -16,7 +16,7 @@ const props = defineProps({
sleepStages: {
type: Array,
required: true
},
}
})
const { t } = useI18n()
@@ -33,10 +33,10 @@ const yAxisLabels = computed(() => [
// Sleep stage mapping with vertical positioning
const SLEEP_STAGES = {
3: { name: 'Awake', color: 'rgba(156, 163, 175, 0.8)', yPos: 3, label: yAxisLabels.value[3] }, // Gray
2: { name: 'REM', color: 'rgba(96, 165, 250, 0.8)', yPos: 2, label: yAxisLabels.value[2] }, // Light Blue
1: { name: 'Light', color: 'rgba(37, 99, 235, 0.8)', yPos: 1, label: yAxisLabels.value[1] }, // Medium Blue
0: { name: 'Deep', color: 'rgba(30, 64, 175, 0.8)', yPos: 0, label: yAxisLabels.value[0] } // Dark Blue
3: { name: 'Awake', color: 'rgba(156, 163, 175, 0.8)', yPos: 3, label: yAxisLabels.value[3] }, // Gray
2: { name: 'REM', color: 'rgba(96, 165, 250, 0.8)', yPos: 2, label: yAxisLabels.value[2] }, // Light Blue
1: { name: 'Light', color: 'rgba(37, 99, 235, 0.8)', yPos: 1, label: yAxisLabels.value[1] }, // Medium Blue
0: { name: 'Deep', color: 'rgba(30, 64, 175, 0.8)', yPos: 0, label: yAxisLabels.value[0] } // Dark Blue
}
const chartData = computed(() => {
@@ -45,12 +45,12 @@ const chartData = computed(() => {
}
// Sort stages by time
const sortedStages = [...props.sleepStages].sort((a, b) =>
new Date(a.start_time_gmt) - new Date(b.start_time_gmt)
const sortedStages = [...props.sleepStages].sort(
(a, b) => new Date(a.start_time_gmt) - new Date(b.start_time_gmt)
)
// Create segments with all necessary data
const allSegments = sortedStages.map(stage => {
const allSegments = sortedStages.map((stage) => {
const startTime = new Date(stage.start_time_gmt)
const endTime = new Date(stage.end_time_gmt)
const stageInfo = SLEEP_STAGES[stage.stage_type]
@@ -69,16 +69,18 @@ const chartData = computed(() => {
// Return single dataset with all segments
return {
datasets: [{
label: t('generalItems.labelSleep'),
data: allSegments,
backgroundColor: function (context) {
return context.raw?.backgroundColor || 'rgba(59, 130, 246, 0.8)'
},
borderWidth: 0,
borderRadius: 0,
barThickness: 40
}]
datasets: [
{
label: t('generalItems.labelSleep'),
data: allSegments,
backgroundColor: function (context) {
return context.raw?.backgroundColor || 'rgba(59, 130, 246, 0.8)'
},
borderWidth: 0,
borderRadius: 0,
barThickness: 40
}
]
}
})
@@ -93,14 +95,18 @@ watch(
{ deep: true }
)
watch(() => props.sleepStages, () => {
if (myChart) {
myChart.options.scales.y.ticks.callback = function (value) {
return yAxisLabels.value[value] || ''
watch(
() => props.sleepStages,
() => {
if (myChart) {
myChart.options.scales.y.ticks.callback = function (value) {
return yAxisLabels.value[value] || ''
}
myChart.update()
}
myChart.update()
}
}, { deep: true })
},
{ deep: true }
)
// Watch for language changes to update y-axis labels and legend
watch(yAxisLabels, () => {
@@ -162,7 +168,7 @@ onMounted(() => {
drawBorder: true,
color: 'rgba(200, 200, 200, 0.3)'
}
},
}
},
plugins: {
legend: {

View File

@@ -4,31 +4,55 @@
<div v-else>
<!-- add steps button -->
<div class="d-flex">
<a class="w-100 btn btn-primary shadow-sm me-1" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addStepsModal">{{ $t('healthStepsZoneComponent.buttonAddSteps') }}</a>
<a class="w-100 btn btn-primary shadow-sm ms-1" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addStepsTargetModal">{{ $t('healthStepsZoneComponent.buttonStepsTarget') }}</a>
<a
class="w-100 btn btn-primary shadow-sm me-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addStepsModal"
>{{ $t('healthStepsZoneComponent.buttonAddSteps') }}</a
>
<a
class="w-100 btn btn-primary shadow-sm ms-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addStepsTargetModal"
>{{ $t('healthStepsZoneComponent.buttonStepsTarget') }}</a
>
</div>
<HealthStepsAddEditModalComponent :action="'add'" @isLoadingNewSteps="updateIsLoadingNewSteps"
@createdSteps="updateStepsListAdded" />
<HealthStepsAddEditModalComponent
:action="'add'"
@isLoadingNewSteps="updateIsLoadingNewSteps"
@createdSteps="updateStepsListAdded"
/>
<ModalComponentNumberInput modalId="addStepsTargetModal" :title="t('healthStepsZoneComponent.buttonStepsTarget')"
:numberFieldLabel="t('healthStepsZoneComponent.modalStepsTargetLabel')" actionButtonType="success"
<ModalComponentNumberInput
modalId="addStepsTargetModal"
:title="t('healthStepsZoneComponent.buttonStepsTarget')"
:numberFieldLabel="t('healthStepsZoneComponent.modalStepsTargetLabel')"
actionButtonType="success"
:actionButtonText="t('generalItems.buttonSubmit')"
:numberDefaultValue="props.userHealthTargets?.steps || parseInt(10000)"
@numberToEmitAction="submitSetStepsTarget" />
@numberToEmitAction="submitSetStepsTarget"
/>
<!-- Checking if userHealthSteps is loaded and has length -->
<div v-if="userHealthSteps && userHealthSteps.length" class="mt-3 p-3 bg-body-tertiary rounded shadow-sm">
<div
v-if="userHealthSteps && userHealthSteps.length"
class="mt-3 p-3 bg-body-tertiary rounded shadow-sm"
>
<!-- show graph -->
<HealthStepsBarChartComponent :userHealthTargets="userHealthTargets" :userHealthSteps="userHealthSteps"
:isLoading="isLoading" />
<HealthStepsBarChartComponent
:userHealthTargets="userHealthTargets"
:userHealthSteps="userHealthSteps"
:isLoading="isLoading"
/>
<br />
<p>
{{ $t('healthStepsZoneComponent.labelNumberOfHealthSteps1')
}}{{ userHealthSteps.length
{{ $t('healthStepsZoneComponent.labelNumberOfHealthSteps1') }}{{ userHealthSteps.length
}}{{ $t('healthStepsZoneComponent.labelNumberOfHealthSteps2')
}}{{ userHealthStepsPagination.length
}}{{ $t('healthStepsZoneComponent.labelNumberOfHealthSteps3') }}
@@ -42,14 +66,25 @@
</ul>
<!-- list zone -->
<ul class="my-3 list-group list-group-flush" v-for="userHealthStep in userHealthStepsPagination"
:key="userHealthStep.id" :userHealthStep="userHealthStep">
<HealthStepsListComponent :userHealthStep="userHealthStep" @deletedSteps="updateStepsListDeleted"
@editedSteps="updateStepsListEdited" />
<ul
class="my-3 list-group list-group-flush"
v-for="userHealthStep in userHealthStepsPagination"
:key="userHealthStep.id"
:userHealthStep="userHealthStep"
>
<HealthStepsListComponent
:userHealthStep="userHealthStep"
@deletedSteps="updateStepsListDeleted"
@editedSteps="updateStepsListEdited"
/>
</ul>
<!-- pagination area -->
<PaginationComponent :totalPages="totalPages" :pageNumber="pageNumber" @pageNumberChanged="setPageNumber" />
<PaginationComponent
:totalPages="totalPages"
:pageNumber="pageNumber"
@pageNumberChanged="setPageNumber"
/>
</div>
<!-- Displaying a message or component when there are no weight measurements -->
<div v-else class="mt-3">
@@ -98,7 +133,13 @@ const props = defineProps({
}
})
const emit = defineEmits(['createdSteps', 'deletedSteps', 'editedSteps', 'pageNumberChanged', 'setStepsTarget'])
const emit = defineEmits([
'createdSteps',
'deletedSteps',
'editedSteps',
'pageNumberChanged',
'setStepsTarget'
])
const { t } = useI18n()
const isLoadingNewSteps = ref(false)
@@ -126,4 +167,4 @@ function setPageNumber(page) {
function submitSetStepsTarget(stepsTarget) {
emit('setStepsTarget', stepsTarget)
}
</script>
</script>

View File

@@ -1,7 +1,12 @@
<template>
<!-- Modal add/edit steps -->
<div class="modal fade" :id="action == 'add' ? 'addStepsModal' : action == 'edit' ? editStepsId : ''" tabindex="-1"
:aria-labelledby="action == 'add' ? 'addStepsModal' : action == 'edit' ? editStepsId : ''" aria-hidden="true">
<div
class="modal fade"
:id="action == 'add' ? 'addStepsModal' : action == 'edit' ? editStepsId : ''"
tabindex="-1"
:aria-labelledby="action == 'add' ? 'addStepsModal' : action == 'edit' ? editStepsId : ''"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -11,16 +16,38 @@
<h1 class="modal-title fs-5" :id="editStepsId" v-else>
{{ $t('healthStepsAddEditModalComponent.editStepsModalTitle') }}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<!-- steps fields -->
<label for="stepsAdd"><b>* {{ $t('healthStepsAddEditModalComponent.addStepsLabel') }}</b></label>
<input class="form-control" type="number" step="0.1" name="stepsAdd" v-model="newEditSteps" required />
<label for="stepsAdd"
><b>* {{ $t('healthStepsAddEditModalComponent.addStepsLabel') }}</b></label
>
<input
class="form-control"
type="number"
step="0.1"
name="stepsAdd"
v-model="newEditSteps"
required
/>
<!-- date fields -->
<label for="stepsDateAdd"><b>* {{ $t('healthStepsAddEditModalComponent.addStepsDateLabel') }}</b></label>
<input class="form-control" type="date" name="stepsDateAdd" v-model="newEditStepsDate" required />
<label for="stepsDateAdd"
><b>* {{ $t('healthStepsAddEditModalComponent.addStepsDateLabel') }}</b></label
>
<input
class="form-control"
type="date"
name="stepsDateAdd"
v-model="newEditStepsDate"
required
/>
<p>* {{ $t('generalItems.requiredField') }}</p>
</div>
@@ -28,7 +55,12 @@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t('generalItems.buttonClose') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-if="action == 'add'">
<button
type="submit"
class="btn btn-success"
data-bs-dismiss="modal"
v-if="action == 'add'"
>
{{ $t('healthStepsAddEditModalComponent.addStepsModalTitle') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-else>

View File

@@ -72,14 +72,12 @@ const chartData = computed(() => {
const data = []
const labels = []
for (const healthSteps of sortedSteps) {
data.push(healthSteps.steps)
const createdAt = new Date(healthSteps.date)
labels.push(
`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`
)
labels.push(`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`)
}
const datasets = [

View File

@@ -16,23 +16,39 @@
</span>
<!-- edit weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editStepsId${userHealthStep.id}`"><font-awesome-icon
:icon="['fas', 'fa-pen-to-square']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editStepsId${userHealthStep.id}`"
><font-awesome-icon :icon="['fas', 'fa-pen-to-square']"
/></a>
<HealthStepsAddEditModalComponent :action="'edit'" :userHealthStep="userHealthStep"
@editedSteps="updateStepsListEdited" />
<HealthStepsAddEditModalComponent
:action="'edit'"
:userHealthStep="userHealthStep"
@editedSteps="updateStepsListEdited"
/>
<!-- delete weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#deleteStepsModal${userHealthStep.id}`"><font-awesome-icon
:icon="['fas', 'fa-trash-can']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteStepsModal${userHealthStep.id}`"
><font-awesome-icon :icon="['fas', 'fa-trash-can']"
/></a>
<ModalComponent :modalId="`deleteStepsModal${userHealthStep.id}`"
<ModalComponent
:modalId="`deleteStepsModal${userHealthStep.id}`"
:title="t('healthStepsListComponent.modalDeleteStepsTitle')"
:body="`${t('healthStepsListComponent.modalDeleteStepsBody')}<b>${userHealthStep.date}</b>?`"
:actionButtonType="`danger`" :actionButtonText="t('healthStepsListComponent.modalDeleteStepsTitle')"
@submitAction="submitDeleteSteps" />
:actionButtonType="`danger`"
:actionButtonText="t('healthStepsListComponent.modalDeleteStepsTitle')"
@submitAction="submitDeleteSteps"
/>
</div>
</li>
</template>

View File

@@ -4,27 +4,51 @@
<div v-else>
<!-- add weight button -->
<div class="d-flex">
<a class="w-100 btn btn-primary shadow-sm me-1" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addWeightModal">{{ $t('healthWeightZoneComponent.buttonAddWeight') }}</a>
<a class="w-100 btn btn-primary shadow-sm ms-1" href="#" role="button" data-bs-toggle="modal"
data-bs-target="#addWeightTargetModal">{{ $t('healthWeightZoneComponent.buttonWeightTarget') }}</a>
<a
class="w-100 btn btn-primary shadow-sm me-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addWeightModal"
>{{ $t('healthWeightZoneComponent.buttonAddWeight') }}</a
>
<a
class="w-100 btn btn-primary shadow-sm ms-1"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addWeightTargetModal"
>{{ $t('healthWeightZoneComponent.buttonWeightTarget') }}</a
>
</div>
<HealthWeightAddEditModalComponent :action="'add'" @isLoadingNewWeight="updateIsLoadingNewWeight"
@createdWeight="updateWeightListAdded" />
<HealthWeightAddEditModalComponent
:action="'add'"
@isLoadingNewWeight="updateIsLoadingNewWeight"
@createdWeight="updateWeightListAdded"
/>
<ModalComponentNumberInput modalId="addWeightTargetModal"
<ModalComponentNumberInput
modalId="addWeightTargetModal"
:title="t('healthWeightZoneComponent.buttonWeightTarget')"
:numberFieldLabel="t('healthWeightZoneComponent.modalWeightTargetLabel')" actionButtonType="success"
:numberFieldLabel="t('healthWeightZoneComponent.modalWeightTargetLabel')"
actionButtonType="success"
:actionButtonText="t('generalItems.buttonSubmit')"
:numberDefaultValue="props.userHealthTargets?.weight || parseInt(70)"
@numberToEmitAction="submitSetWeightTarget" />
@numberToEmitAction="submitSetWeightTarget"
/>
<!-- Checking if userHealthWeight is loaded and has length -->
<div v-if="userHealthWeight && userHealthWeight.length" class="mt-3 p-3 bg-body-tertiary rounded shadow-sm">
<div
v-if="userHealthWeight && userHealthWeight.length"
class="mt-3 p-3 bg-body-tertiary rounded shadow-sm"
>
<!-- show graph -->
<HealthWeightLineChartComponent :userHealthTargets="userHealthTargets" :userHealthWeight="userHealthWeight"
:isLoading="isLoading" />
<HealthWeightLineChartComponent
:userHealthTargets="userHealthTargets"
:userHealthWeight="userHealthWeight"
:isLoading="isLoading"
/>
<br />
<p>
@@ -43,14 +67,25 @@
</ul>
<!-- list zone -->
<ul class="my-3 list-group list-group-flush" v-for="userHealthWeight in userHealthWeightPagination"
:key="userHealthWeight.id" :userHealthWeight="userHealthWeight">
<HealthWeightListComponent :userHealthWeight="userHealthWeight" @deletedWeight="updateWeightListDeleted"
@editedWeight="updateWeightListEdited" />
<ul
class="my-3 list-group list-group-flush"
v-for="userHealthWeight in userHealthWeightPagination"
:key="userHealthWeight.id"
:userHealthWeight="userHealthWeight"
>
<HealthWeightListComponent
:userHealthWeight="userHealthWeight"
@deletedWeight="updateWeightListDeleted"
@editedWeight="updateWeightListEdited"
/>
</ul>
<!-- pagination area -->
<PaginationComponent :totalPages="totalPages" :pageNumber="pageNumber" @pageNumberChanged="setPageNumber" />
<PaginationComponent
:totalPages="totalPages"
:pageNumber="pageNumber"
@pageNumberChanged="setPageNumber"
/>
</div>
<!-- Displaying a message or component when there are no weight measurements -->
<div v-else class="mt-3">
@@ -99,7 +134,13 @@ const props = defineProps({
}
})
const emit = defineEmits(['createdWeight', 'deletedWeight', 'editedWeight', 'pageNumberChanged', 'setWeightTarget'])
const emit = defineEmits([
'createdWeight',
'deletedWeight',
'editedWeight',
'pageNumberChanged',
'setWeightTarget'
])
const { t } = useI18n()
const isLoadingNewWeight = ref(false)

View File

@@ -1,7 +1,12 @@
<template>
<!-- Modal add/edit weight -->
<div class="modal fade" :id="action == 'add' ? 'addWeightModal' : action == 'edit' ? editWeightId : ''" tabindex="-1"
:aria-labelledby="action == 'add' ? 'addWeightModal' : action == 'edit' ? editWeightId : ''" aria-hidden="true">
<div
class="modal fade"
:id="action == 'add' ? 'addWeightModal' : action == 'edit' ? editWeightId : ''"
tabindex="-1"
:aria-labelledby="action == 'add' ? 'addWeightModal' : action == 'edit' ? editWeightId : ''"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -11,78 +16,138 @@
<h1 class="modal-title fs-5" :id="editWeightId" v-else>
{{ $t('healthWeightAddEditModalComponent.editWeightModalTitle') }}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<!-- weight fields -->
<label for="weightWeightAddEdit"><b>* {{ $t('healthWeightAddEditModalComponent.addWeightWeightLabel')
}}</b></label>
<label for="weightWeightAddEdit"
><b>* {{ $t('healthWeightAddEditModalComponent.addWeightWeightLabel') }}</b></label
>
<div class="input-group">
<input class="form-control" type="number" step="0.01" name="weightWeightAddEdit"
v-model="newEditWeightWeight" required />
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{ $t('generalItems.unitsKg')
}}</span>
<input
class="form-control"
type="number"
step="0.01"
name="weightWeightAddEdit"
v-model="newEditWeightWeight"
required
/>
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{
$t('generalItems.unitsKg')
}}</span>
<span class="input-group-text" v-else>{{ $t('generalItems.unitsLbs') }}</span>
</div>
<!-- date fields -->
<label for="weightDateAddEdit"><b>* {{ $t('healthWeightAddEditModalComponent.addWeightDateLabel')
}}</b></label>
<input class="form-control" type="date" name="weightDateAddEdit" v-model="newEditWeightDate" required />
<label for="weightDateAddEdit"
><b>* {{ $t('healthWeightAddEditModalComponent.addWeightDateLabel') }}</b></label
>
<input
class="form-control"
type="date"
name="weightDateAddEdit"
v-model="newEditWeightDate"
required
/>
<div class="d-flex justify-content-start align-items-center">
<span>{{ $t('healthWeightAddEditModalComponent.addWeightHiddenItemsLabel') }}</span>
<!-- button toggle hidden fields -->
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
:href="`#collapseAddEditWeightDetailsFields`" role="button" aria-expanded="false"
:aria-controls="`collapseAddEditWeightDetailsFields`">
<a
class="btn btn-link btn-lg link-body-emphasis"
data-bs-toggle="collapse"
:href="`#collapseAddEditWeightDetailsFields`"
role="button"
aria-expanded="false"
:aria-controls="`collapseAddEditWeightDetailsFields`"
>
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!detailFields" />
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
</div>
<div class="collapse" id="collapseAddEditWeightDetailsFields">
<!-- bmi fields -->
<label for="weightBMIAddEdit"><b>* {{ $t('healthWeightAddEditModalComponent.addWeightBMILabel')
}}</b></label>
<input class="form-control" type="number" step="0.01" name="weightBMIAddEdit"
v-model="newEditWeightBMI" />
<label for="weightBMIAddEdit"
><b>* {{ $t('healthWeightAddEditModalComponent.addWeightBMILabel') }}</b></label
>
<input
class="form-control"
type="number"
step="0.01"
name="weightBMIAddEdit"
v-model="newEditWeightBMI"
/>
<!-- body fat fields -->
<label for="weightBodyFatAddEdit"><b>* {{ $t('healthWeightAddEditModalComponent.addWeightBodyFatLabel')
}}</b></label>
<label for="weightBodyFatAddEdit"
><b>* {{ $t('healthWeightAddEditModalComponent.addWeightBodyFatLabel') }}</b></label
>
<div class="input-group">
<input class="form-control" type="number" step="0.01" name="weightBodyFatAddEdit"
v-model="newEditWeightBodyFat" />
<input
class="form-control"
type="number"
step="0.01"
name="weightBodyFatAddEdit"
v-model="newEditWeightBodyFat"
/>
<span class="input-group-text">%</span>
</div>
<!-- body water fields -->
<label for="weightBodyWaterAddEdit"><b>* {{
$t('healthWeightAddEditModalComponent.addWeightBodyWaterLabel')
}}</b></label>
<label for="weightBodyWaterAddEdit"
><b
>* {{ $t('healthWeightAddEditModalComponent.addWeightBodyWaterLabel') }}</b
></label
>
<div class="input-group">
<input class="form-control" type="number" step="0.01" name="weightBodyWaterAddEdit"
v-model="newEditWeightBodyWater" />
<input
class="form-control"
type="number"
step="0.01"
name="weightBodyWaterAddEdit"
v-model="newEditWeightBodyWater"
/>
<span class="input-group-text">%</span>
</div>
<!-- bone mass fields -->
<label for="weightBoneMassAddEdit"><b>* {{ $t('healthWeightAddEditModalComponent.addWeightBoneMassLabel')
}}</b></label>
<label for="weightBoneMassAddEdit"
><b
>* {{ $t('healthWeightAddEditModalComponent.addWeightBoneMassLabel') }}</b
></label
>
<div class="input-group">
<input class="form-control" type="number" step="0.01" name="weightBoneMassAddEdit"
v-model="newEditWeightBoneMass" />
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{ $t('generalItems.unitsKg')
}}</span>
<input
class="form-control"
type="number"
step="0.01"
name="weightBoneMassAddEdit"
v-model="newEditWeightBoneMass"
/>
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{
$t('generalItems.unitsKg')
}}</span>
<span class="input-group-text" v-else>{{ $t('generalItems.unitsLbs') }}</span>
</div>
<!-- muscle mass fields -->
<label for="weightMuscleMassAddEdit"><b>* {{
$t('healthWeightAddEditModalComponent.addWeightMuscleMassLabel')
}}</b></label>
<label for="weightMuscleMassAddEdit"
><b
>* {{ $t('healthWeightAddEditModalComponent.addWeightMuscleMassLabel') }}</b
></label
>
<div class="input-group">
<input class="form-control" type="number" step="0.01" name="weightMuscleMassAddEdit"
v-model="newEditWeightMuscleMass" />
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{ $t('generalItems.unitsKg')
}}</span>
<input
class="form-control"
type="number"
step="0.01"
name="weightMuscleMassAddEdit"
v-model="newEditWeightMuscleMass"
/>
<span class="input-group-text" v-if="Number(authStore?.user?.units) === 1">{{
$t('generalItems.unitsKg')
}}</span>
<span class="input-group-text" v-else>{{ $t('generalItems.unitsLbs') }}</span>
</div>
</div>
@@ -93,7 +158,12 @@
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t('generalItems.buttonClose') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-if="action == 'add'">
<button
type="submit"
class="btn btn-success"
data-bs-dismiss="modal"
v-if="action == 'add'"
>
{{ $t('healthWeightAddEditModalComponent.addWeightModalTitle') }}
</button>
<button type="submit" class="btn btn-success" data-bs-dismiss="modal" v-else>
@@ -145,7 +215,9 @@ const detailFields = ref(false)
if (props.userHealthWeight) {
newEditWeightWeight.value =
Number(authStore?.user?.units) === 1 ? props.userHealthWeight.weight : kgToLbs(props.userHealthWeight.weight)
Number(authStore?.user?.units) === 1
? props.userHealthWeight.weight
: kgToLbs(props.userHealthWeight.weight)
newEditWeightDate.value = props.userHealthWeight.date
newEditWeightBMI.value = props.userHealthWeight.bmi
newEditWeightBodyFat.value = props.userHealthWeight.body_fat
@@ -167,13 +239,13 @@ async function submitAddWeight() {
try {
let bone_mass = null
let muscle_mass = null
if (newEditWeightBoneMass.value !== null && newEditWeightBoneMass.value !== "") {
if (newEditWeightBoneMass.value !== null && newEditWeightBoneMass.value !== '') {
bone_mass =
Number(authStore?.user?.units) === 1
? newEditWeightBoneMass.value
: lbsToKg(newEditWeightBoneMass.value)
}
if (newEditWeightMuscleMass.value !== null && newEditWeightMuscleMass.value !== "") {
if (newEditWeightMuscleMass.value !== null && newEditWeightMuscleMass.value !== '') {
muscle_mass =
Number(authStore?.user?.units) === 1
? newEditWeightMuscleMass.value
@@ -186,11 +258,20 @@ async function submitAddWeight() {
? newEditWeightWeight.value
: lbsToKg(newEditWeightWeight.value),
date: newEditWeightDate.value,
bmi: newEditWeightBMI.value !== null && newEditWeightBMI.value !== "" ? newEditWeightBMI.value : null,
body_fat: newEditWeightBodyFat.value !== null && newEditWeightBodyFat.value !== "" ? newEditWeightBodyFat.value : null,
body_water: newEditWeightBodyWater.value !== null && newEditWeightBodyWater.value !== "" ? newEditWeightBodyWater.value : null,
bmi:
newEditWeightBMI.value !== null && newEditWeightBMI.value !== ''
? newEditWeightBMI.value
: null,
body_fat:
newEditWeightBodyFat.value !== null && newEditWeightBodyFat.value !== ''
? newEditWeightBodyFat.value
: null,
body_water:
newEditWeightBodyWater.value !== null && newEditWeightBodyWater.value !== ''
? newEditWeightBodyWater.value
: null,
bone_mass: bone_mass,
muscle_mass: muscle_mass,
muscle_mass: muscle_mass
}
const createdWeight = await health_weight.createHealthWeight(data)
@@ -214,13 +295,13 @@ function submitEditWeight() {
console.log(newEditWeightBodyFat.value)
let bone_mass = null
let muscle_mass = null
if (newEditWeightBoneMass.value !== null && newEditWeightBoneMass.value !== "") {
if (newEditWeightBoneMass.value !== null && newEditWeightBoneMass.value !== '') {
bone_mass =
Number(authStore?.user?.units) === 1
? newEditWeightBoneMass.value
: lbsToKg(newEditWeightBoneMass.value)
}
if (newEditWeightMuscleMass.value !== null && newEditWeightMuscleMass.value !== "") {
if (newEditWeightMuscleMass.value !== null && newEditWeightMuscleMass.value !== '') {
muscle_mass =
Number(authStore?.user?.units) === 1
? newEditWeightMuscleMass.value
@@ -234,11 +315,20 @@ function submitEditWeight() {
? newEditWeightWeight.value
: lbsToKg(newEditWeightWeight.value),
date: newEditWeightDate.value,
bmi: newEditWeightBMI.value !== null && newEditWeightBMI.value !== "" ? newEditWeightBMI.value : null,
body_fat: newEditWeightBodyFat.value !== null && newEditWeightBodyFat.value !== "" ? newEditWeightBodyFat.value : null,
body_water: newEditWeightBodyWater.value !== null && newEditWeightBodyWater.value !== "" ? newEditWeightBodyWater.value : null,
bmi:
newEditWeightBMI.value !== null && newEditWeightBMI.value !== ''
? newEditWeightBMI.value
: null,
body_fat:
newEditWeightBodyFat.value !== null && newEditWeightBodyFat.value !== ''
? newEditWeightBodyFat.value
: null,
body_water:
newEditWeightBodyWater.value !== null && newEditWeightBodyWater.value !== ''
? newEditWeightBodyWater.value
: null,
bone_mass: bone_mass,
muscle_mass: muscle_mass,
muscle_mass: muscle_mass
})
}

View File

@@ -97,9 +97,7 @@ const chartData = computed(() => {
}
const createdAt = new Date(HealthWeight.date)
labels.push(
`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`
)
labels.push(`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`)
}
let label = ''
@@ -275,4 +273,4 @@ onUnmounted(() => {
width: 100%;
/* Ensures the canvas stretches across the available width */
}
</style>
</style>

View File

@@ -4,51 +4,94 @@
<div class="d-flex align-items-center">
<div>
<div class="fw-bold">
<span v-if="Number(authStore?.user?.units) === 1">{{ userHealthWeight.weight }} {{
$t('generalItems.unitsKg') }}</span>
<span v-else>{{ kgToLbs(userHealthWeight.weight) }} {{ $t('generalItems.unitsLbs') }}</span>
<span v-if="Number(authStore?.user?.units) === 1"
>{{ userHealthWeight.weight }} {{ $t('generalItems.unitsKg') }}</span
>
<span v-else
>{{ kgToLbs(userHealthWeight.weight) }} {{ $t('generalItems.unitsLbs') }}</span
>
</div>
<span>
{{ $t('healthWeightListComponent.dateLabel') }}: {{ formatDateShort(userHealthWeight.date) }}
{{ $t('healthWeightListComponent.dateLabel') }}:
{{ formatDateShort(userHealthWeight.date) }}
</span>
</div>
</div>
<div>
<!-- button toggle sleep details -->
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
:href="`#collapseWeightDetails${userHealthWeight.id}`" role="button" aria-expanded="false"
<a
class="btn btn-link btn-lg link-body-emphasis"
data-bs-toggle="collapse"
:href="`#collapseWeightDetails${userHealthWeight.id}`"
role="button"
aria-expanded="false"
:aria-controls="`collapseWeightDetails${userHealthWeight.id}`"
v-if="userHealthWeight.bmi || userHealthWeight.body_fat || userHealthWeight.body_water || userHealthWeight.bone_mass || userHealthWeight.muscle_mass">
v-if="
userHealthWeight.bmi ||
userHealthWeight.body_fat ||
userHealthWeight.body_water ||
userHealthWeight.bone_mass ||
userHealthWeight.muscle_mass
"
>
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!weightDetails" />
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
<!-- source logo -->
<span class="align-middle me-3 d-none d-sm-inline" v-if="userHealthWeight.source === 'garmin'">
<span
class="align-middle me-3 d-none d-sm-inline"
v-if="userHealthWeight.source === 'garmin'"
>
<img :src="INTEGRATION_LOGOS.garminConnectApp" alt="Garmin Connect logo" height="22" />
</span>
<!-- edit weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editWeightId${userHealthWeight.id}`"><font-awesome-icon
:icon="['fas', 'fa-pen-to-square']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editWeightId${userHealthWeight.id}`"
><font-awesome-icon :icon="['fas', 'fa-pen-to-square']"
/></a>
<HealthWeightAddEditModalComponent :action="'edit'" :userHealthWeight="userHealthWeight"
@editedWeight="updateWeightListEdited" />
<HealthWeightAddEditModalComponent
:action="'edit'"
:userHealthWeight="userHealthWeight"
@editedWeight="updateWeightListEdited"
/>
<!-- delete weight button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#deleteWeightModal${userHealthWeight.id}`"><font-awesome-icon
:icon="['fas', 'fa-trash-can']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteWeightModal${userHealthWeight.id}`"
><font-awesome-icon :icon="['fas', 'fa-trash-can']"
/></a>
<ModalComponent :modalId="`deleteWeightModal${userHealthWeight.id}`"
<ModalComponent
:modalId="`deleteWeightModal${userHealthWeight.id}`"
:title="t('healthWeightListComponent.modalDeleteWeightTitle')"
:body="`${t('healthWeightListComponent.modalDeleteWeightBody')}<b>${userHealthWeight.date}</b>?`"
:actionButtonType="`danger`" :actionButtonText="t('healthWeightListComponent.modalDeleteWeightTitle')"
@submitAction="submitDeleteWeight" />
:actionButtonType="`danger`"
:actionButtonText="t('healthWeightListComponent.modalDeleteWeightTitle')"
@submitAction="submitDeleteWeight"
/>
</div>
</div>
<div class="collapse" :id="`collapseWeightDetails${userHealthWeight.id}`"
v-if="userHealthWeight.bmi || userHealthWeight.body_fat || userHealthWeight.body_water || userHealthWeight.bone_mass || userHealthWeight.muscle_mass">
<div
class="collapse"
:id="`collapseWeightDetails${userHealthWeight.id}`"
v-if="
userHealthWeight.bmi ||
userHealthWeight.body_fat ||
userHealthWeight.body_water ||
userHealthWeight.bone_mass ||
userHealthWeight.muscle_mass
"
>
<!-- Details -->
<section class="pb-3 mt-3 mb-3">
<h6 class="fw-semibold mb-2">
@@ -58,18 +101,16 @@
<div class="col-12 col-md-6">
<!-- bmi -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthWeightListComponent.bmiLabel') }}:
</span>
<span class="fw-semibold"> {{ $t('healthWeightListComponent.bmiLabel') }}: </span>
<span v-if="userHealthWeight.bmi">{{ userHealthWeight.bmi.toFixed(2) }}</span>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- body_fat -->
<p class="mb-1">
<span class="fw-semibold">
{{ $t('healthWeightListComponent.bodyFatLabel') }}:
</span>
<span v-if="userHealthWeight.body_fat">{{ userHealthWeight.body_fat.toFixed(2) }}%</span>
<span class="fw-semibold"> {{ $t('healthWeightListComponent.bodyFatLabel') }}: </span>
<span v-if="userHealthWeight.body_fat"
>{{ userHealthWeight.body_fat.toFixed(2) }}%</span
>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- body_water -->
@@ -77,7 +118,9 @@
<span class="fw-semibold">
{{ $t('healthWeightListComponent.bodyWaterLabel') }}:
</span>
<span v-if="userHealthWeight.body_water">{{ userHealthWeight.body_water.toFixed(2) }}%</span>
<span v-if="userHealthWeight.body_water"
>{{ userHealthWeight.body_water.toFixed(2) }}%</span
>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
</div>
@@ -87,11 +130,12 @@
<span class="fw-semibold">
{{ $t('healthWeightListComponent.boneMassLabel') }}:
</span>
<span v-if="userHealthWeight.bone_mass && Number(authStore?.user?.units) === 1">{{
userHealthWeight.bone_mass.toFixed(2) }} {{
$t('generalItems.unitsKg') }}</span>
<span v-else-if="userHealthWeight.bone_mass && Number(authStore?.user?.units) === 2">{{
kgToLbs(userHealthWeight.bone_mass) }} {{ $t('generalItems.unitsLbs') }}</span>
<span v-if="userHealthWeight.bone_mass && Number(authStore?.user?.units) === 1"
>{{ userHealthWeight.bone_mass.toFixed(2) }} {{ $t('generalItems.unitsKg') }}</span
>
<span v-else-if="userHealthWeight.bone_mass && Number(authStore?.user?.units) === 2"
>{{ kgToLbs(userHealthWeight.bone_mass) }} {{ $t('generalItems.unitsLbs') }}</span
>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
<!-- muscle_mass -->
@@ -99,11 +143,13 @@
<span class="fw-semibold">
{{ $t('healthWeightListComponent.muscleMassLabel') }}:
</span>
<span v-if="userHealthWeight.muscle_mass && Number(authStore?.user?.units) === 1">{{
userHealthWeight.muscle_mass.toFixed(2) }} {{
$t('generalItems.unitsKg') }}</span>
<span v-else-if="userHealthWeight.muscle_mass && Number(authStore?.user?.units) === 2">{{
kgToLbs(userHealthWeight.muscle_mass) }} {{ $t('generalItems.unitsLbs') }}</span>
<span v-if="userHealthWeight.muscle_mass && Number(authStore?.user?.units) === 1"
>{{ userHealthWeight.muscle_mass.toFixed(2) }}
{{ $t('generalItems.unitsKg') }}</span
>
<span v-else-if="userHealthWeight.muscle_mass && Number(authStore?.user?.units) === 2"
>{{ kgToLbs(userHealthWeight.muscle_mass) }} {{ $t('generalItems.unitsLbs') }}</span
>
<span v-else>{{ $t('generalItems.labelNoData') }}</span>
</p>
</div>
@@ -170,7 +216,9 @@ async function submitDeleteWeight() {
onMounted(async () => {
// Attach Bootstrap collapse event listeners to sync icon state
const collapseElement = document.getElementById(`collapseWeightDetails${props.userHealthWeight.id}`)
const collapseElement = document.getElementById(
`collapseWeightDetails${props.userHealthWeight.id}`
)
if (collapseElement) {
collapseElement.addEventListener('show.bs.collapse', () => {
weightDetails.value = true

View File

@@ -1,14 +1,29 @@
<template>
<li class="list-group-item bg-body-tertiary rounded px-0" :class="{ 'shadow rounded px-3 mb-3': providerDetails }">
<li
class="list-group-item bg-body-tertiary rounded px-0"
:class="{ 'shadow rounded px-3 mb-3': providerDetails }"
>
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center">
<!-- Provider Icon/Logo -->
<div class="me-3">
<img :src="getProviderCustomLogo(provider.icon)!" :alt="`${provider.name} logo`"
<img
:src="getProviderCustomLogo(provider.icon)!"
:alt="`${provider.name} logo`"
style="height: 55px; width: 55px; object-fit: contain"
v-if="provider.icon === 'authelia' || provider.icon === 'authentik' || provider.icon === 'casdoor' || provider.icon === 'keycloak'" />
<img :src="provider.icon" :alt="`${provider.name} logo`"
style="height: 55px; width: 55px; object-fit: contain" v-else />
v-if="
provider.icon === 'authelia' ||
provider.icon === 'authentik' ||
provider.icon === 'casdoor' ||
provider.icon === 'keycloak'
"
/>
<img
:src="provider.icon"
:alt="`${provider.name} logo`"
style="height: 55px; width: 55px; object-fit: contain"
v-else
/>
</div>
<!-- Provider Info -->
@@ -30,43 +45,71 @@
<div class="d-flex align-items-center">
<!-- Status Badge -->
<span v-if="provider.enabled"
<span
v-if="provider.enabled"
class="badge bg-success-subtle border border-success-subtle text-success-emphasis me-2 d-none d-sm-inline"
:aria-label="$t('settingsIdentityProvidersZone.enabledBadge')">{{
$t('settingsIdentityProvidersZone.enabledBadge') }}</span>
<span v-else
:aria-label="$t('settingsIdentityProvidersZone.enabledBadge')"
>{{ $t('settingsIdentityProvidersZone.enabledBadge') }}</span
>
<span
v-else
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
:aria-label="$t('settingsIdentityProvidersZone.disabledBadge')">{{
$t('settingsIdentityProvidersZone.disabledBadge') }}</span>
:aria-label="$t('settingsIdentityProvidersZone.disabledBadge')"
>{{ $t('settingsIdentityProvidersZone.disabledBadge') }}</span
>
<!-- Toggle Collapse Details -->
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
:href="`#collapseProviderDetails${provider.id}`" role="button" aria-expanded="false"
:aria-controls="`collapseProviderDetails${provider.id}`">
<a
class="btn btn-link btn-lg link-body-emphasis"
data-bs-toggle="collapse"
:href="`#collapseProviderDetails${provider.id}`"
role="button"
aria-expanded="false"
:aria-controls="`collapseProviderDetails${provider.id}`"
>
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!providerDetails" />
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
<!-- Toggle Enable/Disable Button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" @click.prevent="handleToggleProvider"
:aria-label="provider.enabled
? $t('settingsIdentityProvidersZone.disableButton')
: $t('settingsIdentityProvidersZone.enableButton')
">
<font-awesome-icon :icon="provider.enabled ? ['fas', 'toggle-on'] : ['fas', 'toggle-off']" />
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
@click.prevent="handleToggleProvider"
:aria-label="
provider.enabled
? $t('settingsIdentityProvidersZone.disableButton')
: $t('settingsIdentityProvidersZone.enableButton')
"
>
<font-awesome-icon
:icon="provider.enabled ? ['fas', 'toggle-on'] : ['fas', 'toggle-off']"
/>
</a>
<!-- Edit Button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editIdentityProviderModal${provider.id}`" @click="handleEditProvider"
:aria-label="$t('settingsIdentityProvidersZone.editButton')">
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editIdentityProviderModal${provider.id}`"
@click="handleEditProvider"
:aria-label="$t('settingsIdentityProvidersZone.editButton')"
>
<font-awesome-icon :icon="['fas', 'edit']" />
</a>
<!-- Delete Button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteIdentityProviderModal${provider.id}`"
:aria-label="$t('settingsIdentityProvidersZone.deleteButton')">
:aria-label="$t('settingsIdentityProvidersZone.deleteButton')"
>
<font-awesome-icon :icon="['fas', 'trash-can']" />
</a>
</div>
@@ -115,14 +158,22 @@
</div>
<!-- Edit Identity Provider Modal -->
<IdentityProvidersAddEditModalComponent :action="'edit'" :provider="provider" :templates="templates"
@providerUpdated="handleProviderUpdated" />
<IdentityProvidersAddEditModalComponent
:action="'edit'"
:provider="provider"
:templates="templates"
@providerUpdated="handleProviderUpdated"
/>
<!-- Delete Confirmation Modal -->
<ModalComponent :modalId="`deleteIdentityProviderModal${provider.id}`"
<ModalComponent
:modalId="`deleteIdentityProviderModal${provider.id}`"
:title="$t('settingsIdentityProvidersZone.deleteModalTitle')"
:body="$t('settingsIdentityProvidersZone.deleteModalBody', { name: provider.name })" actionButtonType="danger"
:actionButtonText="$t('settingsIdentityProvidersZone.deleteModalConfirm')" @submitAction="handleDeleteProvider" />
:body="$t('settingsIdentityProvidersZone.deleteModalBody', { name: provider.name })"
actionButtonType="danger"
:actionButtonText="$t('settingsIdentityProvidersZone.deleteModalConfirm')"
@submitAction="handleDeleteProvider"
/>
</li>
</template>

View File

@@ -1,7 +1,13 @@
<template>
<!-- Modal add/edit identity provider -->
<div ref="modalRef" class="modal fade" :id="action === 'add' ? 'addIdentityProviderModal' : editModalId" tabindex="-1"
:aria-labelledby="headingId" aria-hidden="true">
<div
ref="modalRef"
class="modal fade"
:id="action === 'add' ? 'addIdentityProviderModal' : editModalId"
tabindex="-1"
:aria-labelledby="headingId"
aria-hidden="true"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
@@ -11,16 +17,28 @@
<h1 class="modal-title fs-5" :id="headingId" v-else>
{{ $t('identityProvidersAddEditModal.editTitle') }}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
@click="resetForm"></button>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
@click="resetForm"
></button>
</div>
<form @submit.prevent="handleSubmit">
<div class="modal-body">
<!-- Template Selection (Add Only) -->
<div v-if="action === 'add'" class="mb-3">
<label for="providerTemplate"><b>{{ $t('identityProvidersAddEditModal.templateLabel') }}</b></label>
<select class="form-select" id="providerTemplate" v-model="selectedTemplate" @change="applyTemplate"
aria-label="Provider template selection">
<label for="providerTemplate"
><b>{{ $t('identityProvidersAddEditModal.templateLabel') }}</b></label
>
<select
class="form-select"
id="providerTemplate"
v-model="selectedTemplate"
@change="applyTemplate"
aria-label="Provider template selection"
>
<option value="">{{ $t('identityProvidersAddEditModal.templateCustom') }}</option>
<option v-for="(template, index) in templates" :key="index" :value="index">
{{ template.name }}
@@ -33,82 +51,156 @@
<!-- Basic Information -->
<!-- name field -->
<label :for="`providerName_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.nameLabel') }}</b></label>
<input type="text" class="form-control"
:id="`providerName_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="providerName"
v-model="formData.name" :placeholder="$t('identityProvidersAddEditModal.namePlaceholder')" maxlength="100"
aria-label="Provider name" required />
<label :for="`providerName_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.nameLabel') }}</b></label
>
<input
type="text"
class="form-control"
:id="`providerName_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="providerName"
v-model="formData.name"
:placeholder="$t('identityProvidersAddEditModal.namePlaceholder')"
maxlength="100"
aria-label="Provider name"
required
/>
<!-- slug field -->
<label :for="`providerSlug_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.slugLabel') }}</b></label>
<input type="text" class="form-control" :class="{ 'is-invalid': !isSlugValid }"
:id="`providerSlug_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="providerSlug"
<label :for="`providerSlug_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.slugLabel') }}</b></label
>
<input
type="text"
class="form-control"
:class="{ 'is-invalid': !isSlugValid }"
:id="`providerSlug_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="providerSlug"
:aria-describedby="`validationSlugFeedback_${action === 'add' ? 'add' : `edit_${provider?.id}`} slugHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.slug" :placeholder="$t('identityProvidersAddEditModal.slugPlaceholder')" maxlength="50"
pattern="[0-9a-z-]+" :disabled="action === 'edit'" :aria-invalid="!isSlugValid ? 'true' : 'false'"
required />
<div :id="`slugHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text"
v-if="action === 'add'">
v-model="formData.slug"
:placeholder="$t('identityProvidersAddEditModal.slugPlaceholder')"
maxlength="50"
pattern="[0-9a-z-]+"
:disabled="action === 'edit'"
:aria-invalid="!isSlugValid ? 'true' : 'false'"
required
/>
<div
:id="`slugHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
v-if="action === 'add'"
>
{{ $t('identityProvidersAddEditModal.slugHelp') }}
</div>
<div :id="`validationSlugFeedback_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="invalid-feedback" v-if="!isSlugValid">
<div
:id="`validationSlugFeedback_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="invalid-feedback"
v-if="!isSlugValid"
>
{{ $t('identityProvidersAddEditModal.slugInvalid') }}
</div>
<!-- Provider Type -->
<label :for="`providerType_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.providerTypeLabel') }}</b></label>
<select class="form-select" :id="`providerType_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="providerType" v-model="formData.provider_type" aria-label="Provider type selection" required>
<label :for="`providerType_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.providerTypeLabel') }}</b></label
>
<select
class="form-select"
:id="`providerType_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="providerType"
v-model="formData.provider_type"
aria-label="Provider type selection"
required
>
<option value="oidc">OpenID Connect (OIDC)</option>
<option value="oauth2">OAuth 2.0</option>
</select>
<!-- OAuth/OIDC Configuration -->
<!-- issuer url field -->
<label :for="`issuerUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.issuerUrlLabel') }}</b></label>
<input type="url" class="form-control"
:id="`issuerUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="issuerUrl"
<label :for="`issuerUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.issuerUrlLabel') }}</b></label
>
<input
type="url"
class="form-control"
:id="`issuerUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="issuerUrl"
:aria-describedby="`issuerUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.issuer_url" :placeholder="$t('identityProvidersAddEditModal.issuerUrlPlaceholder')"
maxlength="500" required />
<div :id="`issuerUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
v-model="formData.issuer_url"
:placeholder="$t('identityProvidersAddEditModal.issuerUrlPlaceholder')"
maxlength="500"
required
/>
<div
:id="`issuerUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.issuerUrlHelp') }}
</div>
<!-- client id field -->
<label :for="`clientId_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.clientIdLabel') }}</b></label>
<input type="text" class="form-control"
:id="`clientId_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="clientId"
v-model="formData.client_id" :placeholder="$t('identityProvidersAddEditModal.clientIdPlaceholder')"
maxlength="512" aria-label="Client ID" required />
<label :for="`clientId_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.clientIdLabel') }}</b></label
>
<input
type="text"
class="form-control"
:id="`clientId_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="clientId"
v-model="formData.client_id"
:placeholder="$t('identityProvidersAddEditModal.clientIdPlaceholder')"
maxlength="512"
aria-label="Client ID"
required
/>
<!-- client secret field -->
<label :for="`clientSecret_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>* {{
$t('identityProvidersAddEditModal.clientSecretLabel') }}</b></label>
<input type="password" class="form-control"
:id="`clientSecret_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="clientSecret"
:aria-describedby="action === 'edit' ? `clientSecretHelpText_edit_${provider?.id}` : undefined
" v-model="formData.client_secret" :placeholder="action === 'edit'
<label :for="`clientSecret_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>* {{ $t('identityProvidersAddEditModal.clientSecretLabel') }}</b></label
>
<input
type="password"
class="form-control"
:id="`clientSecret_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="clientSecret"
:aria-describedby="
action === 'edit' ? `clientSecretHelpText_edit_${provider?.id}` : undefined
"
v-model="formData.client_secret"
:placeholder="
action === 'edit'
? $t('identityProvidersAddEditModal.clientSecretPlaceholderEdit')
: $t('identityProvidersAddEditModal.clientSecretPlaceholder')
" maxlength="512" :required="action === 'add'" />
<div :id="`clientSecretHelpText_edit_${provider?.id}`" class="form-text" v-if="action === 'edit'">
"
maxlength="512"
:required="action === 'add'"
/>
<div
:id="`clientSecretHelpText_edit_${provider?.id}`"
class="form-text"
v-if="action === 'edit'"
>
{{ $t('identityProvidersAddEditModal.clientSecretHelpEdit') }}
</div>
<!-- scopes field -->
<label :for="`scopes_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.scopesLabel') }}</b></label>
<input type="text" class="form-control" :id="`scopes_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="scopes" :aria-describedby="`scopesHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.scopes" :placeholder="$t('identityProvidersAddEditModal.scopesPlaceholder')"
maxlength="500" />
<div :id="`scopesHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
<label :for="`scopes_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.scopesLabel') }}</b></label
>
<input
type="text"
class="form-control"
:id="`scopes_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="scopes"
:aria-describedby="`scopesHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.scopes"
:placeholder="$t('identityProvidersAddEditModal.scopesPlaceholder')"
maxlength="500"
/>
<div
:id="`scopesHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.scopesHelp') }}
</div>
@@ -117,30 +209,48 @@
<h6>{{ $t('identityProvidersAddEditModal.appearanceSection') }}</h6>
<!-- icon field -->
<label :for="`icon_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.iconLabel') }}</b></label>
<select class="form-select" :id="`icon_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="icon"
v-model="formData.icon" aria-label="Provider icon selection">
<label :for="`icon_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.iconLabel') }}</b></label
>
<select
class="form-select"
:id="`icon_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="icon"
v-model="formData.icon"
aria-label="Provider icon selection"
>
<option value="authelia">Authelia</option>
<option value="authentik">Authentik</option>
<option value="casdoor">Casdoor</option>
<option value="keycloak">Keycloak</option>
<option value="custom">{{ $t('identityProvidersAddEditModal.iconCustom') }}</option>
</select>
<div :id="`iconHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
<div
:id="`iconHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.iconHelp') }}
</div>
<!-- custom icon url field (shown only when custom is selected) -->
<div v-if="formData.icon === 'custom'" class="mt-2">
<label :for="`customIconUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.customIconUrlLabel') }}</b></label>
<input type="url" class="form-control"
:id="`customIconUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" name="customIconUrl"
<label :for="`customIconUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.customIconUrlLabel') }}</b></label
>
<input
type="url"
class="form-control"
:id="`customIconUrl_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="customIconUrl"
:aria-describedby="`customIconUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.custom_icon_url"
:placeholder="$t('identityProvidersAddEditModal.customIconUrlPlaceholder')" maxlength="500" />
<div :id="`customIconUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
:placeholder="$t('identityProvidersAddEditModal.customIconUrlPlaceholder')"
maxlength="500"
/>
<div
:id="`customIconUrlHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.customIconUrlHelp') }}
</div>
</div>
@@ -150,10 +260,16 @@
<h6>{{ $t('identityProvidersAddEditModal.optionsSection') }}</h6>
<!-- enabled select -->
<label :for="`enabled_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.enabledLabel') }}</b></label>
<select class="form-select" :id="`enabled_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="enabled" v-model="formData.enabled" aria-label="Enable identity provider">
<label :for="`enabled_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.enabledLabel') }}</b></label
>
<select
class="form-select"
:id="`enabled_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="enabled"
v-model="formData.enabled"
aria-label="Enable identity provider"
>
<option :value="true">
{{ $t('generalItems.yes') }}
</option>
@@ -163,12 +279,16 @@
</select>
<!-- auto create users select -->
<label :for="`autoCreateUsers_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.autoCreateUsersLabel') }}</b></label>
<select class="form-select" :id="`autoCreateUsers_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
<label :for="`autoCreateUsers_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.autoCreateUsersLabel') }}</b></label
>
<select
class="form-select"
:id="`autoCreateUsers_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="autoCreateUsers"
:aria-describedby="`autoCreateUsersHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.auto_create_users">
v-model="formData.auto_create_users"
>
<option :value="true">
{{ $t('generalItems.yes') }}
</option>
@@ -176,17 +296,24 @@
{{ $t('generalItems.no') }}
</option>
</select>
<div :id="`autoCreateUsersHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
<div
:id="`autoCreateUsersHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.autoCreateUsersHelp') }}
</div>
<!-- sync user info select -->
<label :for="`syncUserInfo_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"><b>{{
$t('identityProvidersAddEditModal.syncUserInfoLabel') }}</b></label>
<select class="form-select" :id="`syncUserInfo_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
<label :for="`syncUserInfo_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
><b>{{ $t('identityProvidersAddEditModal.syncUserInfoLabel') }}</b></label
>
<select
class="form-select"
:id="`syncUserInfo_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
name="syncUserInfo"
:aria-describedby="`syncUserInfoHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
v-model="formData.sync_user_info">
v-model="formData.sync_user_info"
>
<option :value="true">
{{ $t('generalItems.yes') }}
</option>
@@ -194,7 +321,10 @@
{{ $t('generalItems.no') }}
</option>
</select>
<div :id="`syncUserInfoHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`" class="form-text">
<div
:id="`syncUserInfoHelpText_${action === 'add' ? 'add' : `edit_${provider?.id}`}`"
class="form-text"
>
{{ $t('identityProvidersAddEditModal.syncUserInfoHelp') }}
</div>
@@ -208,17 +338,32 @@
<p>* {{ $t('generalItems.requiredField') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="resetForm"
aria-label="Close modal">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
@click="resetForm"
aria-label="Close modal"
>
{{ $t('generalItems.buttonClose') }}
</button>
<button type="submit" class="btn btn-success" name="providerSubmit" :disabled="isSubmitting || !isSlugValid"
:aria-label="action === 'add'
? $t('identityProvidersAddEditModal.addButton')
: $t('identityProvidersAddEditModal.saveButton')
">
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
<button
type="submit"
class="btn btn-success"
name="providerSubmit"
:disabled="isSubmitting || !isSlugValid"
:aria-label="
action === 'add'
? $t('identityProvidersAddEditModal.addButton')
: $t('identityProvidersAddEditModal.saveButton')
"
>
<span
v-if="isSubmitting"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{
action === 'add'
? $t('identityProvidersAddEditModal.addButton')
@@ -362,8 +507,13 @@ const loadProviderData = (): void => {
if (props.action === 'edit' && props.provider) {
let iconSelect = null
let iconCustom = null
if (props.provider.icon !== "authelia" && props.provider.icon !== "authentik" && props.provider.icon !== "casdoor" && props.provider.icon !== "keycloak") {
iconSelect = "custom"
if (
props.provider.icon !== 'authelia' &&
props.provider.icon !== 'authentik' &&
props.provider.icon !== 'casdoor' &&
props.provider.icon !== 'keycloak'
) {
iconSelect = 'custom'
iconCustom = props.provider.icon
} else {
iconSelect = props.provider.icon
@@ -410,7 +560,12 @@ const createProvider = async (): Promise<void> => {
try {
let icon = null
if (formData.value.icon !== "authelia" && formData.value.icon !== "authentik" && formData.value.icon !== "casdoor" && formData.value.icon !== "keycloak") {
if (
formData.value.icon !== 'authelia' &&
formData.value.icon !== 'authentik' &&
formData.value.icon !== 'casdoor' &&
formData.value.icon !== 'keycloak'
) {
icon = formData.value.custom_icon_url
} else {
icon = formData.value.icon
@@ -452,7 +607,12 @@ const updateProvider = async (): Promise<void> => {
try {
let icon = null
if (formData.value.icon !== "authelia" && formData.value.icon !== "authentik" && formData.value.icon !== "casdoor" && formData.value.icon !== "keycloak") {
if (
formData.value.icon !== 'authelia' &&
formData.value.icon !== 'authentik' &&
formData.value.icon !== 'casdoor' &&
formData.value.icon !== 'keycloak'
) {
icon = formData.value.custom_icon_url
} else {
icon = formData.value.icon

View File

@@ -25,7 +25,12 @@
<!-- Username field -->
<div class="mb-3">
<label for="garminConnectUsername" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel') }}</b>
<b
>*
{{
$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel')
}}</b
>
</label>
<input
id="garminConnectUsername"
@@ -33,8 +38,12 @@
class="form-control"
type="text"
name="garminConnectUsername"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernamePlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel')"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernamePlaceholder')
"
:aria-label="
$t('garminConnectLoginModalComponent.garminConnectAuthModalUsernameLabel')
"
required
/>
</div>
@@ -42,7 +51,12 @@
<!-- Password field -->
<div class="mb-3">
<label for="garminConnectPassword" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel') }}</b>
<b
>*
{{
$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel')
}}</b
>
</label>
<input
id="garminConnectPassword"
@@ -50,8 +64,12 @@
class="form-control"
type="password"
name="garminConnectPassword"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordPlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel')"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordPlaceholder')
"
:aria-label="
$t('garminConnectLoginModalComponent.garminConnectAuthModalPasswordLabel')
"
required
/>
</div>
@@ -60,7 +78,12 @@
<div v-if="mfaRequired" class="row g-3 align-items-end">
<div class="col">
<label for="garminConnectMfaCode" class="form-label">
<b>* {{ $t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel') }}</b>
<b
>*
{{
$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel')
}}</b
>
</label>
<input
id="garminConnectMfaCode"
@@ -68,8 +91,12 @@
class="form-control"
type="text"
name="garminConnectMfaCode"
:placeholder="$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodePlaceholder')"
:aria-label="$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel')"
:placeholder="
$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodePlaceholder')
"
:aria-label="
$t('garminConnectLoginModalComponent.garminConnectAuthModalMfaCodeLabel')
"
/>
</div>
<div class="col">
@@ -199,17 +226,17 @@ const handleWebSocketMessage = (event: MessageEvent): void => {
*/
const submitConnectGarminConnect = async (): Promise<void> => {
loadingLogin.value = true
const notification = push.promise(
t('garminConnectLoginModalComponent.processingMessageLinkGarminConnect')
)
try {
const data = {
username: garminConnectUsername.value,
password: garminConnectPassword.value
}
await garminConnect.linkGarminConnect(data)
// Update user object with linked status
@@ -219,7 +246,7 @@ const submitConnectGarminConnect = async (): Promise<void> => {
// Show success message
notification.resolve(t('garminConnectLoginModalComponent.successMessageLinkGarminConnect'))
// Hide modal and reset form
hideModal()
resetForm()
@@ -237,16 +264,18 @@ const submitConnectGarminConnect = async (): Promise<void> => {
*/
const submitMfaCode = async (): Promise<void> => {
if (!mfaCode.value) return
loadingLoginWithMfa.value = true
try {
const data = {
mfa_code: mfaCode.value
}
await garminConnect.mfaGarminConnect(data)
} catch (error) {
push.error(`${t('garminConnectLoginModalComponent.errorMessageUnableToLinkGarminConnect')} - ${error}`)
push.error(
`${t('garminConnectLoginModalComponent.errorMessageUnableToLinkGarminConnect')} - ${error}`
)
loadingLoginWithMfa.value = false
}
}
@@ -272,7 +301,7 @@ const resetForm = (): void => {
*/
onMounted(async () => {
await initializeModal(modalRef)
// Set up WebSocket message handler for MFA
const websocket = authStore.user_websocket as WebSocket | null
if (websocket) {
@@ -289,7 +318,7 @@ onUnmounted(() => {
if (websocket) {
websocket.onmessage = null
}
disposeModal()
})
</script>

View File

@@ -101,11 +101,7 @@
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0 pb-0">
<div class="d-flex align-items-center">
<!--<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />-->
<img
:src="INTEGRATION_LOGOS.garminConnectApp"
alt="Garmin Connect logo"
height="32"
/>
<img :src="INTEGRATION_LOGOS.garminConnectApp" alt="Garmin Connect logo" height="32" />
<div class="ms-3">
<div class="fw-bold">
{{ $t('settingsIntegrationsZone.garminConnectIntegrationTitle') }}

View File

@@ -1,5 +1,8 @@
<template>
<li class="list-group-item bg-body-tertiary rounded px-0" :class="{ 'shadow rounded px-3 mb-3': userDetails }">
<li
class="list-group-item bg-body-tertiary rounded px-0"
:class="{ 'shadow rounded px-3 mb-3': userDetails }"
>
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center">
<UserAvatarComponent :user="user" :width="55" :height="55" />
@@ -9,97 +12,172 @@
</div>
<span v-if="user.access_type == 1">{{
$t('usersListComponent.userListAccessTypeOption1')
}}</span>
}}</span>
<span v-else>{{ $t('usersListComponent.userListAccessTypeOption2') }}</span>
</div>
</div>
<div>
<span
class="badge bg-secondary-subtle border border-secondary-subtle text-secondary-emphasis me-2 d-none d-sm-inline"
v-if="user.id == authStore.user.id">{{ $t('usersListComponent.userListUserIsMeBadge') }}</span>
<span class="badge bg-warning-subtle border border-warning-subtle text-warning-emphasis me-2 d-none d-sm-inline"
v-if="user.access_type == 2">{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span>
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.active == false">{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span>
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.email_verified == false">{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span>
<span class="badge bg-info-subtle border border-info-subtle text-info-emphasis d-none d-sm-inline"
v-if="user.id == authStore.user.id"
>{{ $t('usersListComponent.userListUserIsMeBadge') }}</span
>
<span
class="badge bg-warning-subtle border border-warning-subtle text-warning-emphasis me-2 d-none d-sm-inline"
v-if="user.access_type == 2"
>{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.active == false"
>{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.email_verified == false"
>{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span
>
<span
class="badge bg-info-subtle border border-info-subtle text-info-emphasis d-none d-sm-inline"
v-if="user.external_auth_count && user.external_auth_count > 0"
:aria-label="$t('usersListComponent.userListUserHasExternalAuthBadge')">{{
$t('usersListComponent.userListUserHasExternalAuthBadge') }}</span>
:aria-label="$t('usersListComponent.userListUserHasExternalAuthBadge')"
>{{ $t('usersListComponent.userListUserHasExternalAuthBadge') }}</span
>
<!-- button toggle user details -->
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
:href="`#collapseUserDetails${user.id}`" role="button" aria-expanded="false"
:aria-controls="`collapseUserDetails${user.id}`">
<a
class="btn btn-link btn-lg link-body-emphasis"
data-bs-toggle="collapse"
:href="`#collapseUserDetails${user.id}`"
role="button"
aria-expanded="false"
:aria-controls="`collapseUserDetails${user.id}`"
>
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!userDetails" />
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
<!-- approve sign-up button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#approveSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"><font-awesome-icon
:icon="['fas', 'fa-check']" /></a>
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-check']"
/></a>
<!-- approve sign up modal -->
<ModalComponent :modalId="`approveSignUpModal${user.id}`"
<ModalComponent
:modalId="`approveSignUpModal${user.id}`"
:title="t('usersListComponent.modalApproveSignUpTitle')"
:body="`${t('usersListComponent.modalApproveSignUpBody')}<b>${user.username}</b>?`"
:actionButtonType="`success`" :actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
@submitAction="submitApproveSignUp" v-if="user.pending_admin_approval && user.email_verified" />
:actionButtonType="`success`"
:actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
@submitAction="submitApproveSignUp"
v-if="user.pending_admin_approval && user.email_verified"
/>
<!-- reject sign-up button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#rejectSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"><font-awesome-icon
:icon="['fas', 'fa-xmark']" /></a>
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-xmark']"
/></a>
<!-- reject sign up modal -->
<ModalComponent :modalId="`rejectSignUpModal${user.id}`" :title="t('usersListComponent.modalRejectSignUpTitle')"
<ModalComponent
:modalId="`rejectSignUpModal${user.id}`"
:title="t('usersListComponent.modalRejectSignUpTitle')"
:body="`${t('usersListComponent.modalRejectSignUpBody1')}<b>${user.username}</b>? ${t('usersListComponent.modalRejectSignUpBody2')}`"
:actionButtonType="`danger`" :actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
@submitAction="submitDeleteUser" v-if="user.pending_admin_approval && user.email_verified" />
:actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
@submitAction="submitDeleteUser"
v-if="user.pending_admin_approval && user.email_verified"
/>
<!-- change user password button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editUserPasswordModal${user.id}`"><font-awesome-icon :icon="['fas', 'fa-key']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editUserPasswordModal${user.id}`"
><font-awesome-icon :icon="['fas', 'fa-key']"
/></a>
<!-- change user password Modal -->
<UsersChangeUserPasswordModalComponent :user="user" />
<!-- edit user button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editUserModal${user.id}`"><font-awesome-icon :icon="['fas', 'fa-pen-to-square']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editUserModal${user.id}`"
><font-awesome-icon :icon="['fas', 'fa-pen-to-square']"
/></a>
<!-- edit user modal -->
<UsersAddEditUserModalComponent :action="'edit'" :user="user" @editedUser="editUserList" />
<!-- delete user button -->
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#deleteUserModal${user.id}`" v-if="authStore.user.id != user.id"><font-awesome-icon
:icon="['fas', 'fa-trash-can']" /></a>
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteUserModal${user.id}`"
v-if="authStore.user.id != user.id"
><font-awesome-icon :icon="['fas', 'fa-trash-can']"
/></a>
<!-- delete user modal -->
<ModalComponent :modalId="`deleteUserModal${user.id}`" :title="t('usersListComponent.modalDeleteUserTitle')"
:body="`${t('usersListComponent.modalDeleteUserBody')}<b>${user.username}</b>?`" :actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalDeleteUserTitle')" @submitAction="submitDeleteUser" />
<ModalComponent
:modalId="`deleteUserModal${user.id}`"
:title="t('usersListComponent.modalDeleteUserTitle')"
:body="`${t('usersListComponent.modalDeleteUserBody')}<b>${user.username}</b>?`"
:actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalDeleteUserTitle')"
@submitAction="submitDeleteUser"
/>
</div>
</div>
<div class="collapse" :id="`collapseUserDetails${user.id}`">
<!-- Bootstrap Tabs Navigation -->
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sessions-tab-${user.id}`" data-bs-toggle="tab" :data-bs-target="`#sessions-${user.id}`" type="button"
role="tab" :aria-controls="`sessions-${user.id}`" aria-selected="true">
<button
class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sessions-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#sessions-${user.id}`"
type="button"
role="tab"
:aria-controls="`sessions-${user.id}`"
aria-selected="true"
>
{{ $t('usersListComponent.tabSessions') }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`idps-tab-${user.id}`" data-bs-toggle="tab" :data-bs-target="`#idps-${user.id}`" type="button"
role="tab" :aria-controls="`idps-${user.id}`" aria-selected="false" @click="loadUserIdpsIfNeeded">
<button
class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`idps-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#idps-${user.id}`"
type="button"
role="tab"
:aria-controls="`idps-${user.id}`"
aria-selected="false"
@click="loadUserIdpsIfNeeded"
>
{{ $t('usersListComponent.tabIdentityProviders') }}
</button>
</li>
@@ -108,26 +186,44 @@
<!-- Tab Content -->
<div class="tab-content mt-3">
<!-- Sessions Tab -->
<div class="tab-pane fade show active" :id="`sessions-${user.id}`" role="tabpanel"
:aria-labelledby="`sessions-tab-${user.id}`">
<div
class="tab-pane fade show active"
:id="`sessions-${user.id}`"
role="tabpanel"
:aria-labelledby="`sessions-tab-${user.id}`"
>
<div v-if="isLoadingSessions">
<LoadingComponent />
</div>
<div v-else-if="userSessions && userSessions.length > 0">
<UserSessionsListComponent v-for="session in userSessions" :key="session.id" :session="session"
@sessionDeleted="updateSessionListDeleted" />
<UserSessionsListComponent
v-for="session in userSessions"
:key="session.id"
:session="session"
@sessionDeleted="updateSessionListDeleted"
/>
</div>
<NoItemsFoundComponents :show-shadow="false" v-else />
</div>
<!-- Identity Providers Tab -->
<div class="tab-pane fade" :id="`idps-${user.id}`" role="tabpanel" :aria-labelledby="`idps-tab-${user.id}`">
<div
class="tab-pane fade"
:id="`idps-${user.id}`"
role="tabpanel"
:aria-labelledby="`idps-tab-${user.id}`"
>
<div v-if="isLoadingIdps">
<LoadingComponent />
</div>
<div v-else-if="userIdps && userIdps.length > 0">
<UserIdentityProviderListComponent v-for="idp in userIdps" :key="idp.id" :idp="idp" :userId="user.id"
@idpDeleted="updateIdpListDeleted" />
<UserIdentityProviderListComponent
v-for="idp in userIdps"
:key="idp.id"
:idp="idp"
:userId="user.id"
@idpDeleted="updateIdpListDeleted"
/>
</div>
<NoItemsFoundComponents :show-shadow="false" v-else />
</div>

View File

@@ -22,14 +22,14 @@ import waterSportsBoard1 from '@/assets/avatar/waterSportsBoard1.png'
* - 8: Water sports board
*/
export const GEAR_AVATAR_MAP: Record<number, string> = {
1: bicycle1,
2: runningShoe1,
3: wetsuit1,
4: racquet1,
5: skis1,
6: snowboard1,
7: windsurf1,
8: waterSportsBoard1
1: bicycle1,
2: runningShoe1,
3: wetsuit1,
4: racquet1,
5: skis1,
6: snowboard1,
7: windsurf1,
8: waterSportsBoard1
} as const
/**
@@ -39,5 +39,5 @@ export const GEAR_AVATAR_MAP: Record<number, string> = {
* @returns The image path for the specified gear type, or the default bicycle avatar if not found.
*/
export function getGearAvatar(gearType: number): string {
return GEAR_AVATAR_MAP[gearType] ?? bicycle1
return GEAR_AVATAR_MAP[gearType] ?? bicycle1
}

View File

@@ -12,7 +12,7 @@ import garminConnectApp from '@/assets/garminconnect/Garmin_Connect_app_1024x102
* - **garminConnectApp**: Garmin Connect app icon (1024x1024)
*/
export const INTEGRATION_LOGOS = {
strava: stravaLogo,
garminConnectBadge: garminConnectBadge,
garminConnectApp: garminConnectApp
strava: stravaLogo,
garminConnectBadge: garminConnectBadge,
garminConnectApp: garminConnectApp
} as const

View File

@@ -17,5 +17,5 @@ export const PROVIDER_CUSTOM_LOGO_MAP: Record<string, string> = {
authelia: autheliaLogo,
authentik: authentikLogo,
casdoor: casdoorLogo,
keycloak: keycloakLogo,
keycloak: keycloakLogo
} as const

View File

@@ -12,9 +12,9 @@ import unspecifiedAvatar from '@/assets/avatar/unspecified1.png'
* - **3**: Unspecified/neutral avatar
*/
export const USER_AVATAR_MAP: Record<number, string> = {
1: maleAvatar,
2: femaleAvatar,
3: unspecifiedAvatar
1: maleAvatar,
2: femaleAvatar,
3: unspecifiedAvatar
} as const
/**
@@ -28,8 +28,8 @@ export const USER_AVATAR_MAP: Record<number, string> = {
* Falls back to unspecified avatar if the gender value is not found in the map.
*/
export function getUserDefaultAvatar(gender?: number): string {
if (!gender || gender < 1 || gender > 3) {
return maleAvatar // Default to male avatar
}
return USER_AVATAR_MAP[gender] ?? unspecifiedAvatar
if (!gender || gender < 1 || gender > 3) {
return maleAvatar // Default to male avatar
}
return USER_AVATAR_MAP[gender] ?? unspecifiedAvatar
}

View File

@@ -1,16 +1,16 @@
/// <reference types="vite/client" />
declare module '*.svg' {
const content: string
export default content
const content: string
export default content
}
declare module '*.png' {
const content: string
export default content
const content: string
export default content
}
declare module '*.jpg' {
const content: string
export default content
const content: string
export default content
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Raquetes de neu",
"inlineSkating": "Patinatge en línia",
"labelWorkout": " entrenament"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Amaga equipament",
"successActivityEdit": "Activitat editada correctament",
"errorActivityEdit": "Error editant activitat"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Hi ha un total de ",
"labelNumberOfHealthWeightWeight2": " mesure(s) de pes inserides (",
"labelNumberOfHealthWeightWeight3": " carregat):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "No es poden obtenir dades de salut de GC",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "No es poden obtenir dades de salut de GC",
"loadingMessageRetrievingGarminConnectHealthData": "Recuperant dades de salut de Garmin Connect"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Salut",
"errorFetchingHealthWeight": "Error recollint dades de salut",
"errorFetchingHealthTargets": "Error recollint objectius de salut"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Error obtenint usuari amb lògica de nom d'usuari",
"errorFetchingActivityWithNameContains": "Error obtenint activitat amb lògica de nom d'usuari",
"errorFetchingGearWithNicknameContains": "Error obtenint equipament amb lògica de nom d'usuari"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " 运动"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "隐藏装备",
"successActivityEdit": "活动编辑成功",
"errorActivityEdit": "活动编辑失败"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "共有",
"labelNumberOfHealthWeightWeight2": "条体重记录(",
"labelNumberOfHealthWeightWeight3": "条已加载):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "无法按天获取 Garmin Connect 健康数据",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "无法按日期范围获取 Garmin Connect 健康数据",
"loadingMessageRetrievingGarminConnectHealthData": "正在获取 Garmin Connect 健康数据"
}
}

View File

@@ -25,4 +25,4 @@
"userIdpsLoadErrorMessage": "加载身份提供商时出错",
"userIdpDeleteSuccessMessage": "身份提供商已成功解除链接",
"userIdpDeleteErrorMessage": "解除身份提供商链接时出错"
}
}

View File

@@ -2,4 +2,4 @@
"title": "健康",
"errorFetchingHealthWeight": "获取健康数据出错",
"errorFetchingHealthTargets": "获取健康目标出错"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "获取用户名包含逻辑的用户时出错",
"errorFetchingActivityWithNameContains": "获取名称中包含逻辑的活动时出错",
"errorFetchingGearWithNicknameContains": "获取昵称中包含逻辑的装备时出错"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " Workout"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Ausrüstung ausblenden",
"successActivityEdit": "Aktivität erfolgreich bearbeitet",
"errorActivityEdit": "Fehler beim Bearbeiten der Aktivität"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Insgesamt ",
"labelNumberOfHealthWeightWeight2": " Gewichtsmessung(en) hinzugefügt (",
"labelNumberOfHealthWeightWeight3": " geladen):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "Garmin Connect Gesundheitsdaten konnten nicht nach Tagen abgerufen werden",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "Garmin Connect Gesundheitsdaten konnten nicht nach Datumsbereich abgerufen werden",
"loadingMessageRetrievingGarminConnectHealthData": "Gesundheitsdaten von Garmin Connect abrufen"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Gesundheit",
"errorFetchingHealthWeight": "Fehler beim Laden der Gesundheitsdaten",
"errorFetchingHealthTargets": "Fehler beim Laden der Gesundheitsziele"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Fehler beim Abrufen des Benutzers mit Benutzername enthält Logik",
"errorFetchingActivityWithNameContains": "Fehler beim Abrufen der Aktivität mit dem Namen enthält Logik",
"errorFetchingGearWithNicknameContains": "Fehler beim Abrufen der Ausrüstung mit Nickname enthält Logik"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " entrenamiento"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Ocultar equipo",
"successActivityEdit": "Actividad editada correctamente",
"errorActivityEdit": "Error al editar actividad"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Hay un total de ",
"labelNumberOfHealthWeightWeight2": " medición(es) de peso insertado(s) (",
"labelNumberOfHealthWeightWeight3": " cargado):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "No se pueden obtener datos de salud de Garmin Connect por días",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "No se pueden obtener las actividades de Garmin Connect usando el rango de datos",
"loadingMessageRetrievingGarminConnectHealthData": "Obteniendo datos de salud de Garmin Connect"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Salud",
"errorFetchingHealthWeight": "Error obteniendo datos de salud",
"errorFetchingHealthTargets": "Error obteniendo objetivos de salud"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Error obteniendo usuario con nombre de usuario contiene lógica",
"errorFetchingActivityWithNameContains": "Error al obtener la actividad con el nombre contiene lógica",
"errorFetchingGearWithNicknameContains": "Error al obtener el equipo con el nombre contiene lógica"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " workout"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Masquer l'équipement",
"successActivityEdit": "L'activité a bien été modifiée",
"errorActivityEdit": "Erreur lors de la modification de l'activité"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Il y a un total de ",
"labelNumberOfHealthWeightWeight2": " mesure(s) de poids insérée(s) (",
"labelNumberOfHealthWeightWeight3": " chargé(e)(s) ):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "Impossible d'obtenir les données de santé de Garmin Connect par jours",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "Impossible d'obtenir les données de santé de Garmin Connect par période",
"loadingMessageRetrievingGarminConnectHealthData": "Récupération des données de santé de Garmin Connect"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Santé",
"errorFetchingHealthWeight": "Erreur lors de la récupération des données de santé",
"errorFetchingHealthTargets": "Erreur lors de la récupération des objectifs de santé"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Erreur lors de la récupération de l'utilisateur avec le nom d'utilisateur",
"errorFetchingActivityWithNameContains": "Erreur lors de la récupération de l'activité avec le nom",
"errorFetchingGearWithNicknameContains": "Erreur lors de la récupération de l'équipement avec le surnom"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " adestramento"
}
}

View File

@@ -10,4 +10,4 @@
"errorMessageActivityNotFound": "Non se atopa a actividade",
"alertPrivacyMessage": "Tes información oculta nesta actividade. Podes vela, pero outras persoas non.",
"isHiddenMessage": "A actividade está oculta. Probablemente porque é un duplicado ou a usuaria así o decideu."
}
}

View File

@@ -28,4 +28,4 @@
"activityNoData": "Sen datos",
"errorFetchingUserById": "Erro ao obter a usuaria polo id",
"errorDeletingActivity": "Erro ao eliminar a actividade"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Ocultar equipamento",
"successActivityEdit": "Actividade editada correctamente",
"errorActivityEdit": "Erro ao editar a actividade"
}
}

View File

@@ -13,4 +13,4 @@
"errorDeleteFollower": "Erro ao eliminar a seguidora",
"errorUpdateFollower": "Erro ao actualizar a seguidora",
"errorFetchingFollowersDetails": "Erro ao obter detalles das seguidoras"
}
}

View File

@@ -26,4 +26,4 @@
"errorGearAdd": "Erro ao engadir o equipamento",
"successGearEdited": "Equipamento editado correctamente",
"errorGearEdit": "Erro ao editar o equipamento"
}
}

View File

@@ -13,4 +13,4 @@
"gearListModalDeleteGearBody": "Tes certeza de querer eliminar o equipamento ",
"gearListGearDeleteSuccessMessage": "Equipamento eliminado correctamente",
"gearListGearDeleteErrorMessage": "Erro ao eliminar o equipamento"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Hai un total de ",
"labelNumberOfHealthWeightWeight2": " engadido o peso (",
"labelNumberOfHealthWeightWeight3": " cargado):"
}
}

View File

@@ -1,4 +1,4 @@
{
"title": "Oooi…",
"subtitle": "Sen records"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "Non se puideron obter os datos diarios de saúde desde Garmin Connect",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "Non se puideron obter os datos de saúde do rango de datas desde Garmin Connect",
"loadingMessageRetrievingGarminConnectHealthData": "Obtendo datos de saúde desde Garmin Connect"
}
}

View File

@@ -28,4 +28,4 @@
"successGearEdited": "Equipamento editado correctamente",
"errorGearDelete": "Erro ao eliminar o equipamento",
"errorFetchingGears": "Erro ao obter equipamentos"
}
}

View File

@@ -10,4 +10,4 @@
"successGearDeleted": "Equipamento eliminado correctamente",
"errorGearNotFound": "Non se atopa equipamento",
"errorFetchingGears": "Erro ao obter equipamentos"
}
}

View File

@@ -79,4 +79,4 @@
"genderUnspecified": "Sen especificar",
"labelAverage": "Average",
"labelMaximum": "Maximum"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Saúde",
"errorFetchingHealthWeight": "Erro ao obter datos de saúde",
"errorFetchingHealthTargets": "Erro ao obter obxectivos de saúde"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Ooooi! Non atopamos a páxina",
"subTitle": "A páxina que solicitas non existe ou cambiou.",
"backToHomeButton": "Volver ao inicio"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Error fetching user with username contains logic",
"errorFetchingActivityWithNameContains": "Error fetching activity with name contains logic",
"errorFetchingGearWithNicknameContains": "Error fetching gear with nickname contains logic"
}
}

View File

@@ -42,7 +42,10 @@ const componentPaths = {
'components/health/healthStepsZone/healthStepsAddEditModalComponent.json',
healthSleepZoneComponent: 'components/health/healthSleepZoneComponent.json',
healthSleepListComponent: 'components/health/healthSleepZone/healthSleepListComponent.json',
healthSleepListTabsComponent: 'components/health/healthSleepZone/healthSleepListTabsComponent.json',
healthSleepListTabsComponent:
'components/health/healthSleepZone/healthSleepListTabsComponent.json',
healthSleepAddEditModalComponent:
'components/health/healthSleepZone/healthSleepAddEditModalComponent.json',
healthRHRZoneComponent: 'components/health/healthRHRZoneComponent.json',
// Navbar components
navbarBottomMobileComponent: 'components/navbar/navbarBottomMobileComponent.json',

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " allenamento"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Nascondi attrezzatura",
"successActivityEdit": "Attività modificata con successo",
"errorActivityEdit": "Errore nella modifica dell'attività"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "C'è un totale di ",
"labelNumberOfHealthWeightWeight2": " misurazioni di peso inserite (",
"labelNumberOfHealthWeightWeight3": " caricate):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "Impossibile ottenere i dati sulla salute Garmin Connect per giorni",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "Impossibile ottenere i dati sulla salute Garmin Connect usando l'intervallo di date",
"loadingMessageRetrievingGarminConnectHealthData": "Recupero dati sulla salute Garmin Connect"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Salute",
"errorFetchingHealthWeight": "Errore nel recupero dei dati sulla salute",
"errorFetchingHealthTargets": "Errore nel recupero degli obiettivi di salute"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Errore nel recupero dell'utente con nome utente contenente",
"errorFetchingActivityWithNameContains": "Errore nel recupero dell'attività con nome contenente",
"errorFetchingGearWithNicknameContains": "Errore nel recupero dell'attrezzatura con soprannome contenente"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " workout"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Verberg uitrusting",
"successActivityEdit": "Activiteit succesvol bewerkt",
"errorActivityEdit": "Fout bij bewerken activiteit"
}
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Er is een totaal van ",
"labelNumberOfHealthWeightWeight2": " gewicht afmeting(en) ingevoegd (",
"labelNumberOfHealthWeightWeight3": " geladen):"
}
}

View File

@@ -44,4 +44,4 @@
"errorMessageUnableToGetGarminConnectHealthDataDays": "Unable to get Garmin Connect health data by days",
"errorMessageUnableToGetGarminConnectHealthDataDateRange": "Garmin Connect gezondheidsgegevens ophalen voor datumbereik is niet gelukt",
"loadingMessageRetrievingGarminConnectHealthData": "Ophalen van Garmin Connect gezondheidsgegevens"
}
}

View File

@@ -2,4 +2,4 @@
"title": "Gezondheid",
"errorFetchingHealthWeight": "Fout bij het ophalen van gezondheidsgegevens",
"errorFetchingHealthTargets": "Fout bij ophalen van gezondheidsdoelen"
}
}

View File

@@ -39,4 +39,4 @@
"errorFetchingUserWithUsernameContains": "Fout bij ophalen gebruiker met gebruikersnaam bevat logica",
"errorFetchingActivityWithNameContains": "Fout bij ophalen van activiteit met naam bevat logica",
"errorFetchingGearWithNicknameContains": "Fout bij ophalen van uitrusting met bijnaam bevat logica"
}
}

View File

@@ -45,4 +45,4 @@
"snowShoeing": "Snow shoeing",
"inlineSkating": "Inline skating",
"labelWorkout": " treino"
}
}

View File

@@ -71,4 +71,4 @@
"modalEditActivityHideGearLabel": "Ocultar equipamento",
"successActivityEdit": "Atividade editada com sucesso",
"errorActivityEdit": "Erro ao editar atividade"
}
}

View File

@@ -0,0 +1,36 @@
{
"addSleepModalTitle": "Adicionar sono",
"editSleepModalTitle": "Editar sono",
"dateLabel": "Data",
"sleepStartTimeLabel": "Hora de início do sono",
"sleepEndTimeLabel": "Hora de fim do sono",
"totalSleepLabel": "Sono Total",
"deepSleepLabel": "Sono Profundo",
"lightSleepLabel": "Sono Leve",
"remSleepLabel": "Sono REM",
"awakeSleepLabel": "Sono Acordado",
"napTimeLabel": "Tempo de Sesta",
"avgHeartRateLabel": "Frequência cardíaca média",
"minHeartRateLabel": "Frequência cardíaca mínima",
"maxHeartRateLabel": "Frequência cardíaca máxima",
"avgSpo2Label": "SpO2 médio",
"lowestSpo2Label": "SpO2 mínimo",
"highestSpo2Label": "SpO2 máximo",
"sleepScoreOverallLabel": "Pontuação geral do sono",
"awakeCountLabel": "Número de despertares",
"restlessMomentsCountLabel": "Momentos inquietos",
"sleepStagesLabel": "Fases do sono",
"addStageButton": "Adicionar fase",
"stageLabel": "Fase",
"stageTypeLabel": "Tipo de fase",
"stageTypeDeep": "Profundo",
"stageTypeLight": "Leve",
"stageTypeRem": "REM",
"stageTypeAwake": "Acordado",
"stageStartTimeLabel": "Hora de início",
"stageEndTimeLabel": "Hora de fim",
"stageDurationLabel": "Duração (segundos)",
"noStagesMessage": "Nenhuma fase de sono adicionada ainda. Clique em 'Adicionar fase' para adicionar uma fase de sono.",
"successAddSleep": "Sono adicionado com sucesso",
"errorAddSleep": "Erro ao adicionar sono"
}

View File

@@ -3,4 +3,4 @@
"labelNumberOfHealthWeightWeight1": "Existe um total de ",
"labelNumberOfHealthWeightWeight2": " medida(s) de peso inserida(s) (",
"labelNumberOfHealthWeightWeight3": " carregada(s)):"
}
}

Some files were not shown because too many files have changed in this diff Show More