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:
João Vitória Silva
2025-11-27 12:16:37 +00:00
parent 399c954563
commit 19e282ed26
6 changed files with 230 additions and 131 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
{
"labelDate": "Date"
"labelDate": "Date",
"sleepStagesTitle": "Sleep stages"
}

View File

@@ -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",

View File

@@ -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",