mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-10 00:07:57 -05:00
Enhance sleep zone UI with improved labels and chart
Refactored HealthSleepListTabsComponent to add summary and breakdown sections with clearer labels and structure. Updated HealthSleepTimelineChartComponent to use internationalized y-axis labels and improved legend, ensuring better localization and clarity. Added new i18n keys for sleep stages and related labels in both component and general items translation files. Minor UI improvements for consistency and readability.
This commit is contained in:
@@ -13,35 +13,25 @@
|
||||
</div>
|
||||
<div>
|
||||
<!-- button toggle sleep details -->
|
||||
<a
|
||||
class="btn btn-link btn-lg link-body-emphasis"
|
||||
data-bs-toggle="collapse"
|
||||
:href="`#collapseSleepDetails${userHealthSleep.id}`"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
:aria-controls="`collapseSleepDetails${userHealthSleep.id}`"
|
||||
>
|
||||
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
|
||||
:href="`#collapseSleepDetails${userHealthSleep.id}`" role="button" aria-expanded="false"
|
||||
:aria-controls="`collapseSleepDetails${userHealthSleep.id}`">
|
||||
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!sleepDetails" />
|
||||
<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="userHealthSleep.source === 'garmin'"
|
||||
>
|
||||
<img
|
||||
:src="INTEGRATION_LOGOS.garminConnectApp"
|
||||
alt="Garmin Connect logo"
|
||||
height="22"
|
||||
/>
|
||||
<span class="align-middle me-3 d-none d-sm-inline" v-if="userHealthSleep.source === 'garmin'">
|
||||
<img :src="INTEGRATION_LOGOS.garminConnectApp" alt="Garmin Connect logo" height="22" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" :id="`collapseSleepDetails${userHealthSleep.id}`">
|
||||
<HealthSleepListTabsComponent :userHealthSleep="userHealthSleep" />
|
||||
<HealthSleepTimelineChartComponent
|
||||
:sleepStages="userHealthSleep.sleep_stages"
|
||||
/>
|
||||
|
||||
<h6 class="fw-semibold mb-2">
|
||||
{{ $t('healthSleepListComponent.sleepStagesTitle') }}
|
||||
</h6>
|
||||
<HealthSleepTimelineChartComponent :sleepStages="userHealthSleep.sleep_stages" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -2,30 +2,17 @@
|
||||
<!-- 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-${userHealthSleep.id}`"
|
||||
data-bs-toggle="tab"
|
||||
:data-bs-target="`#sessions-${userHealthSleep.id}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-controls="`sessions-${userHealthSleep.id}`"
|
||||
aria-selected="true"
|
||||
>
|
||||
<button class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
|
||||
:id="`sessions-tab-${userHealthSleep.id}`" data-bs-toggle="tab"
|
||||
:data-bs-target="`#sessions-${userHealthSleep.id}`" type="button" role="tab"
|
||||
:aria-controls="`sessions-${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="`idps-tab-${userHealthSleep.id}`"
|
||||
data-bs-toggle="tab"
|
||||
:data-bs-target="`#idps-${userHealthSleep.id}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-controls="`idps-${userHealthSleep.id}`"
|
||||
aria-selected="false"
|
||||
>
|
||||
<button class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
|
||||
:id="`idps-tab-${userHealthSleep.id}`" data-bs-toggle="tab" :data-bs-target="`#idps-${userHealthSleep.id}`"
|
||||
type="button" role="tab" :aria-controls="`idps-${userHealthSleep.id}`" aria-selected="false">
|
||||
{{ $t('healthSleepListTabsComponent.sleepDetailsLabel') }}
|
||||
</button>
|
||||
</li>
|
||||
@@ -34,29 +21,91 @@
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content mt-3">
|
||||
<!-- Score tab -->
|
||||
<div
|
||||
class="tab-pane fade show active"
|
||||
:id="`sessions-${userHealthSleep.id}`"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="`sessions-tab-${userHealthSleep.id}`"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p v-if="userHealthSleep.sleep_score_overall">{{ $t('healthSleepListTabsComponent.scoreLabel') }}: {{ userHealthSleep.sleep_score_overall }}</p>
|
||||
<p v-if="userHealthSleep.sleep_score_quality">{{ $t('healthSleepListTabsComponent.qualityLabel') }}: {{ userHealthSleep.sleep_score_quality }}</p>
|
||||
<p v-if="userHealthSleep.sleep_score_duration">{{ $t('healthSleepListTabsComponent.durationLabel') }}: {{ $t(getScoreStatusI18nKey(userHealthSleep.sleep_score_duration)) }}</p>
|
||||
<p v-if="userHealthSleep.hrv_status">
|
||||
HRV Status: {{ $t(getHrvStatusI18nKey(userHealthSleep.hrv_status)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>Deep: {{ formatDuration(userHealthSleep.deep_sleep_seconds) }}</p>
|
||||
<p>Light: {{ formatDuration(userHealthSleep.light_sleep_seconds) }}</p>
|
||||
<p>REM: {{ formatDuration(userHealthSleep.rem_sleep_seconds) }}</p>
|
||||
<p>Awake: {{ formatDuration(userHealthSleep.awake_sleep_seconds) }}</p>
|
||||
<div class="tab-pane fade show active" :id="`sessions-${userHealthSleep.id}`" role="tabpanel"
|
||||
:aria-labelledby="`sessions-tab-${userHealthSleep.id}`">
|
||||
<!-- Sleep summary -->
|
||||
<section class="pb-3 mb-3 border-bottom">
|
||||
<h6 class="fw-semibold mb-2">
|
||||
{{ $t('healthSleepListTabsComponent.summaryTitle') }}
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-if="userHealthSleep.sleep_score_overall" class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.scoreLabel') }}:
|
||||
</span>
|
||||
{{ userHealthSleep.sleep_score_overall }}
|
||||
</p>
|
||||
<p v-if="userHealthSleep.sleep_score_quality" class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.qualityLabel') }}:
|
||||
</span>
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.sleep_score_quality)) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<p v-if="userHealthSleep.sleep_score_duration" class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.durationLabel') }}:
|
||||
</span>
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.sleep_score_duration)) }}
|
||||
</p>
|
||||
<p v-if="userHealthSleep.hrv_status" class="mb-0">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.HRVLabel') }}:
|
||||
</span>
|
||||
{{ $t(getHrvStatusI18nKey(userHealthSleep.hrv_status)) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Breakdown -->
|
||||
<section class="pb-3 mb-3 border-bottom">
|
||||
<h6 class="fw-semibold mb-2">
|
||||
{{ $t('healthSleepListTabsComponent.breakdownTitle') }}
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<p class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.deepLabel') }}:
|
||||
</span>
|
||||
{{ formatDuration(userHealthSleep.deep_sleep_seconds) }}
|
||||
-
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.deep_percentage_score)) }}
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.REMLabel') }}:
|
||||
</span>
|
||||
{{ formatDuration(userHealthSleep.rem_sleep_seconds) }}
|
||||
-
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.rem_percentage_score)) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<p class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.lightLabel') }}:
|
||||
</span>
|
||||
{{ formatDuration(userHealthSleep.light_sleep_seconds) }}
|
||||
-
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.light_percentage_score)) }}
|
||||
</p>
|
||||
<p class="mb-1">
|
||||
<span class="fw-semibold">
|
||||
{{ $t('healthSleepListTabsComponent.awakeLabel') }}:
|
||||
</span>
|
||||
{{ formatDuration(userHealthSleep.awake_sleep_seconds) }}
|
||||
-
|
||||
{{ $t(getScoreStatusI18nKey(userHealthSleep.awake_count_score)) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
@@ -23,55 +23,38 @@ const { t } = useI18n()
|
||||
const chartCanvas = ref(null)
|
||||
let myChart = null
|
||||
|
||||
// Y-axis labels for sleep stages (internationalized)
|
||||
const yAxisLabels = computed(() => [
|
||||
t('generalItems.labelDeep'),
|
||||
t('generalItems.labelLight'),
|
||||
t('generalItems.labelREM'),
|
||||
t('generalItems.labelAwake')
|
||||
])
|
||||
|
||||
// Sleep stage mapping with vertical positioning
|
||||
const SLEEP_STAGES = {
|
||||
3: { name: 'Awake', color: 'rgba(156, 163, 175, 0.8)', yPos: 3 }, // Gray
|
||||
2: { name: 'REM', color: 'rgba(96, 165, 250, 0.8)', yPos: 2 }, // Light Blue
|
||||
1: { name: 'Light', color: 'rgba(37, 99, 235, 0.8)', yPos: 1 }, // Medium Blue
|
||||
0: { name: 'Deep', color: 'rgba(30, 64, 175, 0.8)', yPos: 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
|
||||
}
|
||||
|
||||
// Custom crosshair plugin
|
||||
const crosshairPlugin = {
|
||||
id: 'customCrosshair',
|
||||
afterDraw: (chart) => {
|
||||
if (chart.tooltip?._active && chart.tooltip._active.length) {
|
||||
const ctx = chart.ctx
|
||||
const activePoint = chart.tooltip._active[0]
|
||||
const x = activePoint.element.x
|
||||
const topY = chart.scales.y.top
|
||||
const bottomY = chart.scales.y.bottom
|
||||
|
||||
// Draw vertical line
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.setLineDash([5, 5])
|
||||
ctx.moveTo(x, topY)
|
||||
ctx.lineTo(x, bottomY)
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepareChartData() {
|
||||
const chartData = computed(() => {
|
||||
if (!props.sleepStages || props.sleepStages.length === 0) {
|
||||
return { datasets: [] }
|
||||
}
|
||||
|
||||
// Sort stages by time
|
||||
const sortedStages = [...props.sleepStages].sort((a, b) =>
|
||||
const sortedStages = [...props.sleepStages].sort((a, b) =>
|
||||
new Date(a.start_time_gmt) - new Date(b.start_time_gmt)
|
||||
)
|
||||
|
||||
// Create a single dataset with all segments
|
||||
// Create segments with all necessary data
|
||||
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]
|
||||
|
||||
|
||||
return {
|
||||
x: [startTime, endTime],
|
||||
y: stageInfo.yPos,
|
||||
@@ -79,40 +62,60 @@ function prepareChartData() {
|
||||
startTimeStr: formatTime(stage.start_time_gmt),
|
||||
endTimeStr: formatTime(stage.end_time_gmt),
|
||||
duration: stage.duration_seconds,
|
||||
color: stageInfo.color,
|
||||
backgroundColor: stageInfo.color,
|
||||
stageType: stage.stage_type
|
||||
}
|
||||
})
|
||||
|
||||
// Return single dataset with all segments
|
||||
return {
|
||||
datasets: [{
|
||||
label: 'Sleep Stages',
|
||||
label: t('generalItems.labelSleep'),
|
||||
data: allSegments,
|
||||
backgroundColor: function(context) {
|
||||
return context.raw?.color || 'rgba(59, 130, 246, 0.8)'
|
||||
backgroundColor: function (context) {
|
||||
return context.raw?.backgroundColor || 'rgba(59, 130, 246, 0.8)'
|
||||
},
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
barThickness: 40
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function updateChart() {
|
||||
if (myChart) {
|
||||
myChart.data = prepareChartData()
|
||||
myChart.update()
|
||||
}
|
||||
}
|
||||
watch(
|
||||
chartData,
|
||||
(newChartData) => {
|
||||
if (myChart) {
|
||||
myChart.data = newChartData
|
||||
myChart.update()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(() => props.sleepStages, () => {
|
||||
updateChart()
|
||||
if (myChart) {
|
||||
myChart.options.scales.y.ticks.callback = function (value) {
|
||||
return yAxisLabels.value[value] || ''
|
||||
}
|
||||
myChart.update()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for language changes to update y-axis labels and legend
|
||||
watch(yAxisLabels, () => {
|
||||
if (myChart) {
|
||||
myChart.options.scales.y.ticks.callback = function (value) {
|
||||
return yAxisLabels.value[value] || ''
|
||||
}
|
||||
myChart.update()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
myChart = new Chart(chartCanvas.value.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: prepareChartData(),
|
||||
data: chartData.value,
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
@@ -122,6 +125,26 @@ onMounted(() => {
|
||||
intersect: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 3,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: function (value) {
|
||||
return yAxisLabels.value[value] || ''
|
||||
},
|
||||
font: {
|
||||
size: 12
|
||||
},
|
||||
color: '#666'
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: true,
|
||||
color: 'rgba(200, 200, 200, 0.3)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
@@ -140,31 +163,56 @@ onMounted(() => {
|
||||
color: 'rgba(200, 200, 200, 0.3)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 3,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: function(value) {
|
||||
const labels = ['Deep', 'Light', 'REM', 'Awake']
|
||||
return labels[value] || ''
|
||||
},
|
||||
font: {
|
||||
size: 12
|
||||
},
|
||||
color: '#666'
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: true,
|
||||
color: 'rgba(200, 200, 200, 0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
font: {
|
||||
size: 12
|
||||
},
|
||||
generateLabels: function () {
|
||||
return [
|
||||
{
|
||||
text: yAxisLabels.value[0], // Deep
|
||||
fillStyle: SLEEP_STAGES[0].color,
|
||||
fontColor: '#666',
|
||||
hidden: false,
|
||||
index: 0,
|
||||
strokeStyle: 'transparent',
|
||||
lineWidth: 0
|
||||
},
|
||||
{
|
||||
text: yAxisLabels.value[1], // Light
|
||||
fillStyle: SLEEP_STAGES[1].color,
|
||||
fontColor: '#666',
|
||||
hidden: false,
|
||||
index: 1,
|
||||
strokeStyle: 'transparent',
|
||||
lineWidth: 0
|
||||
},
|
||||
{
|
||||
text: yAxisLabels.value[2], // REM
|
||||
fillStyle: SLEEP_STAGES[2].color,
|
||||
fontColor: '#666',
|
||||
hidden: false,
|
||||
index: 2,
|
||||
strokeStyle: 'transparent',
|
||||
lineWidth: 0
|
||||
},
|
||||
{
|
||||
text: yAxisLabels.value[3], // Awake
|
||||
fillStyle: SLEEP_STAGES[3].color,
|
||||
fontColor: '#666',
|
||||
hidden: false,
|
||||
index: 3,
|
||||
strokeStyle: 'transparent',
|
||||
lineWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"labelDate": "Date"
|
||||
"labelDate": "Date",
|
||||
"sleepStagesTitle": "Sleep stages"
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
"scoreLabel": "Score",
|
||||
"qualityLabel": "Quality",
|
||||
"durationLabel": "Duration",
|
||||
"HRVLabel": "HRV",
|
||||
"deepLabel": "Deep",
|
||||
"lightLabel": "Light",
|
||||
"REMLabel": "REM",
|
||||
"awakeLabel": "Awake",
|
||||
"summaryTitle": "Summary",
|
||||
"breakdownTitle": "Breakdown",
|
||||
"balancedLabel": "Balanced",
|
||||
"unbalancedLabel": "Unbalanced",
|
||||
"lowLabel": "Low",
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
"labelStepsTarget": "Steps target",
|
||||
"labelSleep": "Sleep",
|
||||
"labelSleepTarget": "Sleep target",
|
||||
"labelDeep": "Deep",
|
||||
"labelLight": "Light",
|
||||
"labelREM": "REM",
|
||||
"labelAwake": "Awake",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Heart rate in bpm",
|
||||
|
||||
Reference in New Issue
Block a user