From f9a7dfa7806a1d138ccc37a8c6ad1bd90b2a0ef5 Mon Sep 17 00:00:00 2001 From: ujix Date: Sat, 13 Jun 2026 15:11:11 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Android:=20Sleep=20night-browsing=20?= =?UTF-8?q?=E2=80=94=20accent=20nav=20header,=20date=20split,=20DatePicker?= =?UTF-8?q?Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns NightNavHeader to match DayNavBar: left/right chevrons flanking an accent-tinted center block showing the night label and date. The time range moves to a separate row below. Tapping the block opens a DatePickerDialog to jump to any recorded night by calendar date. Also fixes nightOffset reset: moves it into LaunchedEffect(days) so it only resets on a real sync/import, not on every optimistic sleeps update. --- .../src/main/java/com/noop/ui/SleepScreen.kt | 131 ++++++++++++------ 1 file changed, 89 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 75c132e9..fd1c3832 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -37,9 +38,12 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import android.app.DatePickerDialog +import java.util.Calendar import com.noop.analytics.AnalyticsEngine import com.noop.data.DailyMetric import com.noop.data.SleepSession @@ -93,6 +97,10 @@ fun SleepScreen(vm: AppViewModel) { // mergeSleep but WITHOUT the per-night collapse). Keyed on `days` so a sync/import (which always // rewrites dailyMetric too) reloads; these reads have no Flow. (#160, #170) var sleeps by remember { mutableStateOf>(emptyList()) } + // 0 = latest night, N = N sleep-sessions back. Snaps back to the newest night only on a + // real data reload (new sync / re-import via days changing). Optimistic UI updates do NOT + // reset this so the user stays on the night they just navigated to. (#160) + var nightOffset by remember { mutableIntStateOf(0) } LaunchedEffect(days) { sleeps = runCatching { val now = System.currentTimeMillis() / 1000L @@ -102,14 +110,9 @@ fun SleepScreen(vm: AppViewModel) { val computedOnly = computed.filter { AnalyticsEngine.dayString(it.endTs) !in importedDays } (imported + computedOnly).sortedBy { it.startTs } }.getOrDefault(emptyList()) + nightOffset = 0 } - // 0 = latest night, N = N sleep-sessions back. Snaps back to the newest night when the - // list itself changes (new night synced / re-import); structural equality keeps no-op - // reloads from resetting a browse in progress. (#160) - var nightOffset by remember { mutableIntStateOf(0) } - LaunchedEffect(sleeps) { nightOffset = 0 } - // Export-verbatim sleep figures (sleep_performance / consistency / need / debt) — the // headline tiles prefer them over the on-device approximations. Keyed on `days` so a // fresh import (which always rewrites dailyMetric too) reloads; metricSeries has no Flow. @@ -137,6 +140,12 @@ fun SleepScreen(vm: AppViewModel) { } val display = remember(model, night) { heroDisplay(model, night) } + val onPickNightDate: (LocalDate) -> Unit = { targetDate -> + val targetStr = targetDate.toString() + val idx = sleeps.indexOfLast { s -> localDayString(s.endTs) == targetStr } + if (idx >= 0) nightOffset = sleeps.lastIndex - idx + } + ScreenScaffold(title = "Sleep", subtitle = "Last night, read in two seconds.") { if (model == null && night == null) { // While the strap is mid-offload, say so — "No nights" reads as final otherwise (#77). @@ -149,6 +158,7 @@ fun SleepScreen(vm: AppViewModel) { nightOffset = nightOffset, lastIndex = max(sleeps.lastIndex, 0), onNavigate = { nightOffset = it }, + onPickNightDate = onPickNightDate, ) if (model != null) { Spacer(Modifier.height(Metrics.selectorTopUp)) @@ -171,9 +181,10 @@ private fun Hero( nightOffset: Int, lastIndex: Int, onNavigate: (Int) -> Unit, + onPickNightDate: ((LocalDate) -> Unit)? = null, ) { Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { - NightNavHeader(nightOffset, lastIndex, clock, onNavigate) + NightNavHeader(nightOffset, lastIndex, clock, onNavigate, onPickNightDate) if (display == null) { // Honest fallback: this night recorded no usable stage data — never silently // substitute another night's hypnogram. (#160) @@ -233,9 +244,8 @@ private fun Hero( } /** - * Hero header with ◀/▶ to browse past nights. ◀ goes older (offset+1), ▶ newer; each is - * disabled at its bound — tinted tertiary when disabled, accent when active. Mirrors the - * macOS nightNavHeader (SleepView.swift). (#160) + * Hero header with ◀/▶ to browse past nights. Tapping the accent center block opens a + * DatePickerDialog to jump to any recorded night by date. Mirrors [DayNavBar]. (#160) */ @Composable private fun NightNavHeader( @@ -243,48 +253,85 @@ private fun NightNavHeader( lastIndex: Int, clock: String?, onNavigate: (Int) -> Unit, + onPickNightDate: ((LocalDate) -> Unit)? = null, ) { val canGoOlder = offset < lastIndex val canGoNewer = offset > 0 - Row( - horizontalArrangement = Arrangement.spacedBy(Metrics.space12), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { if (canGoOlder) onNavigate(offset + 1) }, - enabled = canGoOlder, - modifier = Modifier.size(Metrics.iconButton), + val context = LocalContext.current + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker && onPickNightDate != null) { + val cal = Calendar.getInstance() + DisposableEffect(Unit) { + val dialog = DatePickerDialog( + context, + { _, year, month, day -> + onPickNightDate(LocalDate.of(year, month + 1, day)) + showDatePicker = false + }, + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH), + cal.get(Calendar.DAY_OF_MONTH), + ).apply { + datePicker.maxDate = System.currentTimeMillis() + setOnDismissListener { showDatePicker = false } + } + dialog.show() + onDispose { runCatching { dialog.dismiss() } } + } + } + + val nightLabel = when (offset) { + 0 -> "Last night" + 1 -> "1 night ago" + else -> "$offset nights ago" + } + val blockShape = androidx.compose.foundation.shape.RoundedCornerShape(Metrics.cornerSm) + val clockParts = clock?.split(" · ", limit = 2) + val dateLabel = clockParts?.getOrNull(0) + val timeLabel = clockParts?.getOrNull(1) + + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space6)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Metrics.selectorSpacing), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Filled.ChevronLeft, - contentDescription = "Previous night", - tint = if (canGoOlder) Palette.accent else Palette.textTertiary, - ) + IconButton(onClick = { if (canGoOlder) onNavigate(offset + 1) }, enabled = canGoOlder) { + Icon(Icons.Filled.ChevronLeft, contentDescription = "Previous night", tint = if (canGoOlder) Palette.accent else Palette.textTertiary) + } + Column( + modifier = Modifier + .weight(1f) + .clip(blockShape) + .background(Palette.accent.copy(alpha = StrandAlpha.selectedFill)) + .border(Metrics.divider, Palette.accent.copy(alpha = StrandAlpha.selectedBorder), blockShape) + .clickable(enabled = onPickNightDate != null) { showDatePicker = true } + .padding(vertical = Metrics.selectorPadding, horizontal = Metrics.selectorPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(nightLabel, style = NoopType.caption, color = Palette.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (dateLabel != null) { + Text(dateLabel, style = NoopType.captionNumber, color = Palette.accent, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + IconButton(onClick = { if (canGoNewer) onNavigate(offset - 1) }, enabled = canGoNewer) { + Icon(Icons.Filled.ChevronRight, contentDescription = "Next night", tint = if (canGoNewer) Palette.accent else Palette.textTertiary) + } } - Column(modifier = Modifier.weight(1f)) { - Overline( - if (offset == 0) "Last night" else "$offset night${if (offset == 1) "" else "s"} ago", - color = Palette.textTertiary, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { Text( - clock ?: "—", - style = NoopType.headline, - color = Palette.textPrimary, + timeLabel ?: clock ?: "—", + style = NoopType.captionNumber, + color = Palette.accent, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - IconButton( - onClick = { if (canGoNewer) onNavigate(offset - 1) }, - enabled = canGoNewer, - modifier = Modifier.size(Metrics.iconButton), - ) { - Icon( - Icons.Filled.ChevronRight, - contentDescription = "Next night", - tint = if (canGoNewer) Palette.accent else Palette.textTertiary, - ) - } } } From 191fd1e151054e03777de36340f296609a7f342b Mon Sep 17 00:00:00 2001 From: ujix Date: Sat, 13 Jun 2026 15:52:06 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Android:=20Sleep=20analytics=20=E2=80=94=20?= =?UTF-8?q?Hours=20vs=20Needed=20card=20and=20Sleep=20Consistency=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HoursVsNeededCard: score %, trend arrow, gradient progress bar, stacked component bar (Healthy / Strain / Debt), slept/needed/debt footer. SleepConsistencyCard: Canvas vertical bar chart (bed-time top, wake-time bottom), dashed typical overlay lines, Y-axis time labels, X-axis day labels. Score is count-based (nights where both bed and wake are within 45 min of the user's typical); the previous SD formula always returned 0 %. --- .../src/main/java/com/noop/ui/SleepScreen.kt | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index fd1c3832..a1412fca 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow @@ -167,6 +168,10 @@ fun SleepScreen(vm: AppViewModel) { StagesVsTypical(model) Spacer(Modifier.height(Metrics.selectorTopUp)) DurationTrend(model) + Spacer(Modifier.height(Metrics.selectorTopUp)) + HoursVsNeededCard(model) + Spacer(Modifier.height(Metrics.selectorTopUp)) + SleepConsistencyCard(sleeps) } } } @@ -1156,3 +1161,315 @@ internal fun parsePersistedSegments(json: String?): List? { out.takeIf { it.size >= 2 } }.getOrNull() } +// MARK: - Hours vs Needed card + +@Composable +internal fun HoursVsNeededCard(m: SleepModel) { + val sleptH = (m.stages.asleep / 60.0) + val neededH = (m.trendNeedHours.lastOrNull() ?: 8.0) + val debtH = m.trendDebtHours.lastOrNull() ?: 0.0 + val score = (sleptH / neededH * 100.0).coerceIn(0.0, 100.0) + val trendArrow = if (m.trendHours.size >= 2) { + val delta = m.trendHours.last() - m.trendHours[m.trendHours.lastIndex - 1] + when { + delta > 0.25 -> "↑" + delta < -0.25 -> "↓" + else -> "→" + } + } else "→" + val arrowColor = when (trendArrow) { + "↑" -> Palette.statusPositive + "↓" -> Palette.statusCritical + else -> Palette.textTertiary + } + + NoopCard(padding = Metrics.cardPadding) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space14)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Overline("Sleep") + Text("Hours vs Needed", style = NoopType.headline, color = Palette.textPrimary) + } + Text(trendArrow, style = NoopType.title2, color = arrowColor) + Spacer(Modifier.width(6.dp)) + Text("${score.roundToInt()}%", style = NoopType.chartValue, color = Palette.accent) + } + + // Gradient progress bar: slept / needed. + Box( + modifier = Modifier + .fillMaxWidth() + .height(Metrics.progressHeight) + .clip(RoundedCornerShape(Metrics.cornerPill)) + .background(Palette.surfaceInset), + ) { + Box( + modifier = Modifier + .fillMaxWidth((sleptH / neededH).coerceIn(0.0, 1.0).toFloat()) + .height(Metrics.progressHeight) + .clip(RoundedCornerShape(Metrics.cornerPill)) + .background(Brush.horizontalGradient(listOf(Palette.accent.copy(alpha = 0.6f), Palette.accent))), + ) + } + + // Stacked component bar: Healthy Min / Strain buffer / Debt repayment. + val healthyMin = 7.0 + val strainBuffer = (neededH - healthyMin).coerceAtLeast(0.0) + val debtRepay = debtH.coerceAtLeast(0.0) + val totalBar = (healthyMin + strainBuffer + debtRepay).coerceAtLeast(1.0) + Row(modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(Metrics.cornerPill))) { + Box(modifier = Modifier.weight((healthyMin / totalBar).toFloat()).background(Palette.metricPurple)) + if (strainBuffer > 0) Box(modifier = Modifier.weight((strainBuffer / totalBar).toFloat()).background(Palette.strain066)) + if (debtRepay > 0) Box(modifier = Modifier.weight((debtRepay / totalBar).toFloat()).background(Palette.statusCritical)) + } + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space14)) { + LegendDot("Healthy Min", Palette.metricPurple) + LegendDot("Strain", Palette.strain066) + LegendDot("Debt", Palette.statusCritical) + } + + Box(modifier = Modifier.fillMaxWidth().height(Metrics.divider).background(Palette.hairline)) + Row(modifier = Modifier.fillMaxWidth()) { + listOf( + "Slept" to String.format(Locale.US, "%.1f h", sleptH), + "Needed" to String.format(Locale.US, "%.1f h", neededH), + "Debt" to if (debtH > 0.05) String.format(Locale.US, "%.1f h", debtH) else "None", + ).forEach { (lbl, v) -> + Column(modifier = Modifier.weight(1f)) { + Overline(lbl, color = Palette.textTertiary) + Text(v, style = NoopType.captionNumber, color = Palette.textPrimary) + } + } + } + } + } +} + +@Composable +private fun LegendDot(label: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(modifier = Modifier.size(6.dp).clip(RoundedCornerShape(50)).background(color)) + Text(label, style = NoopType.footnote, color = Palette.textTertiary) + } +} + +// MARK: - Sleep Consistency card + +@Composable +internal fun SleepConsistencyCard(sleeps: List) { + val recent = sleeps.takeLast(14) + if (recent.size < 3) return + + data class NightTiming(val label: String, val bedHour: Float, val wakeHour: Float) + val sdf = java.text.SimpleDateFormat("EEE", java.util.Locale.US) + val timings = recent.map { s -> + val bedCal = Calendar.getInstance().apply { timeInMillis = s.startTs * 1000L } + val wakeCal = Calendar.getInstance().apply { timeInMillis = s.endTs * 1000L } + val bedH = bedCal.get(Calendar.HOUR_OF_DAY) + bedCal.get(Calendar.MINUTE) / 60f + val bedNorm = if (bedH > 12f) bedH - 24f else bedH + val wakeH = wakeCal.get(Calendar.HOUR_OF_DAY) + wakeCal.get(Calendar.MINUTE) / 60f + NightTiming(sdf.format(Date(s.endTs * 1000L)), bedNorm, wakeH) + } + + fun sd(vals: List): Float { + val m = vals.average().toFloat() + return kotlin.math.sqrt(vals.sumOf { ((it - m) * (it - m)).toDouble() }.toFloat() / vals.size) + } + val bedSdH = sd(timings.map { it.bedHour }) + val wakeSdH = sd(timings.map { it.wakeHour }) + val typicalBed = timings.map { it.bedHour }.average().toFloat() + val typicalWake = timings.map { it.wakeHour }.average().toFloat() + // Count nights where bed AND wake are within 45 min of the typical. + val threshold = 0.75f + val consistentNights = timings.count { t -> + abs(t.bedHour - typicalBed) <= threshold && abs(t.wakeHour - typicalWake) <= threshold + } + val consistencyPct = (consistentNights.toFloat() / timings.size * 100f).coerceIn(0f, 100f) + val typicalBedLabel = run { + val h = ((typicalBed + 24f) % 24f).toInt() + String.format(Locale.US, "%02d:00", h) + } + val typicalWakeLabel = String.format(Locale.US, "%02d:00", typicalWake.toInt().coerceIn(0, 23)) + + // Y from −4h (20:00) to 14h (14:00 next day) — covers late risers. + val yMin = -4f; val yMax = 14f; val yRange = yMax - yMin + + fun hourToLabel(h: Float): String { + val norm = ((h % 24f) + 24f) % 24f + return String.format(Locale.US, "%02d:00", norm.toInt()) + } + + NoopCard(padding = Metrics.cardPadding) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space14)) { + // Header: title + trend-score + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Overline("Schedule") + Text("Bedtime & wake time", style = NoopType.headline, color = Palette.textPrimary) + Text("Sleep window over recent nights", style = NoopType.footnote, color = Palette.textSecondary) + } + Text("${consistencyPct.roundToInt()}%", style = NoopType.chartValue, color = Palette.accent) + } + + // Canvas chart — clipped so bars never bleed outside the 160dp box. + val accentColor = Palette.accent + val purpleColor = Palette.metricPurple + val hairlineColor = Palette.hairline + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(Metrics.cornerSm)) + .drawBehind { + val yAxisW = 52f + val chartW = size.width - yAxisW + val chartH = size.height + + val gridHours = listOf(-4f, 0f, 4f, 8f, 12f) + val paint = android.graphics.Paint().apply { + color = Palette.textTertiary.toArgb() + textSize = 26f + isAntiAlias = true + } + gridHours.forEach { h -> + val y = (chartH * ((h - yMin) / yRange)).coerceIn(0f, chartH) + drawLine(color = hairlineColor, start = Offset(yAxisW, y), end = Offset(size.width, y), strokeWidth = 1f) + // Draw label below the gridline (top of chart = earliest time). + val textY = (y + 16f).coerceIn(20f, chartH - 4f) + drawContext.canvas.nativeCanvas.drawText(hourToLabel(h), 0f, textY, paint) + } + + // Per-night bars (bed → wake), coordinates clamped to [0, chartH]. + val barW = (chartW / timings.size * 0.6f).coerceAtLeast(4f) + val step = chartW / timings.size + timings.forEachIndexed { i, t -> + val cx = yAxisW + step * i + step / 2f + val rawBedY = chartH * ((t.bedHour - yMin) / yRange) + val rawWakeY = chartH * ((t.wakeHour - yMin) / yRange) + val topY = minOf(rawBedY, rawWakeY).coerceIn(0f, chartH) + val botY = maxOf(rawBedY, rawWakeY).coerceIn(0f, chartH) + val barH = (botY - topY).coerceAtLeast(4f) + drawRoundRect( + color = accentColor.copy(alpha = 0.65f), + topLeft = Offset(cx - barW / 2f, topY), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f), + ) + } + + // Dashed typical bed (purple) / wake (accent) overlay lines. + val dashLen = 12f; val gapLen = 8f + listOf(typicalBed to purpleColor, typicalWake to accentColor).forEach { (h, col) -> + val y = (chartH * ((h - yMin) / yRange)).coerceIn(0f, chartH) + var x = yAxisW + while (x < size.width) { + drawLine(col.copy(alpha = 0.7f), Offset(x, y), Offset(minOf(x + dashLen, size.width), y), strokeWidth = 2f) + x += dashLen + gapLen + } + } + }, + ) {} + + // X-axis day labels (first, mid, last). + Row(modifier = Modifier.fillMaxWidth().padding(start = 52.dp)) { + val xLabels = listOf( + timings.firstOrNull()?.label.orEmpty(), + timings.getOrNull(timings.size / 2)?.label.orEmpty(), + timings.lastOrNull()?.label.orEmpty(), + ) + xLabels.forEach { lbl -> + Text(lbl, style = NoopType.footnote, color = Palette.textTertiary, modifier = Modifier.weight(1f)) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space14)) { + LegendDot("Typical bedtime $typicalBedLabel", Palette.metricPurple) + LegendDot("Wake $typicalWakeLabel", Palette.accent) + } + + Box(modifier = Modifier.fillMaxWidth().height(Metrics.divider).background(Palette.hairline)) + Row(modifier = Modifier.fillMaxWidth()) { + listOf( + "Score" to "${consistencyPct.roundToInt()}%", + "Typical" to "${((bedSdH + wakeSdH) / 2f * 60f).roundToInt()} min SD", + "Nights" to "${recent.size}", + ).forEach { (lbl, v) -> + Column(modifier = Modifier.weight(1f)) { + Overline(lbl, color = Palette.textTertiary) + Text(v, style = NoopType.captionNumber, color = Palette.textPrimary) + } + } + } + } + } +} + +// MARK: - Sleep Metric Detail screen + +private enum class SleepMetricRange(val label: String, val days: Long?) { + WEEK("W", 7), MONTH("M", 30), THREE_MONTH("3M", 90), + SIX_MONTH("6M", 180), YEAR("1Y", 365), ALL("ALL", null), +} + +private data class SleepMetricSpec( + val title: String, + val unit: String, + val color: Color, + val format: (Double) -> String, +) + +private fun sleepMetricSpec(key: String): SleepMetricSpec = when (key) { + "performance" -> SleepMetricSpec("Rest", "%", Palette.accent) { "${it.roundToInt()}" } + "efficiency" -> SleepMetricSpec("Sleep Efficiency", "%", Palette.statusPositive) { "${it.roundToInt()}" } + "consistency" -> SleepMetricSpec("Consistency", "%", Palette.metricCyan) { "${it.roundToInt()}" } + "hours_vs_needed"-> SleepMetricSpec("Hours vs Needed", "%", Palette.accent) { "${it.roundToInt()}" } + "restorative" -> SleepMetricSpec("Restorative", "%", Palette.sleepREM) { "${it.roundToInt()}" } + "respiratory" -> SleepMetricSpec("Respiratory Rate", "rpm", Palette.metricPurple) { String.format(Locale.US, "%.1f", it) } + "sleep_debt" -> SleepMetricSpec("Sleep Debt", "h", Palette.metricRose) { String.format(Locale.US, "%.1f", it) } + else -> SleepMetricSpec(key, "", Palette.accent) { "${it.roundToInt()}" } +} + +private fun buildSleepMetricPoints(days: List, key: String): List> { + val needMin = max(450.0, days.mapNotNull { it.totalSleepMin?.takeIf { m -> m > 0.0 } }.average().let { if (it.isNaN()) 480.0 else it }) + return days.mapNotNull { d -> + val v: Double? = when (key) { + "performance" -> d.totalSleepMin?.takeIf { it > 0.0 && needMin > 0.0 }?.let { minOf(100.0, it / needMin * 100.0) } + "efficiency" -> d.efficiency?.let { if (it <= 1.0) it * 100.0 else it } + "consistency" -> { + val idx = days.indexOf(d) + val lo = max(0, idx - 13) + val window = days.subList(lo, idx + 1).mapNotNull { it.totalSleepMin?.takeIf { m -> m > 0.0 } } + if (window.size < 3) null else { + val m = window.average() + val sd = kotlin.math.sqrt(window.sumOf { (it - m) * (it - m) } / window.size) + (100.0 * (1.0 - sd / 90.0)).coerceIn(0.0, 100.0) + } + } + "hours_vs_needed" -> d.totalSleepMin?.takeIf { it > 0.0 }?.let { minOf(100.0, it / needMin * 100.0) } + "restorative" -> { + val dp = d.deepMin ?: return@mapNotNull null + val rm = d.remMin ?: return@mapNotNull null + val sl = d.totalSleepMin ?: return@mapNotNull null + if (sl > 0.0) (dp + rm) / sl * 100.0 else null + } + "respiratory" -> d.respRateBpm + "sleep_debt" -> d.totalSleepMin?.let { max(0.0, needMin - it) / 60.0 } + else -> null + } + v?.takeIf { it.isFinite() }?.let { d.day to it } + } +} + +private fun filterSleepMetricPoints( + points: List>, + range: SleepMetricRange, +): List> { + val windowDays = range.days ?: return points + val latestDate = points.lastOrNull()?.first?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + ?: return points.takeLast(windowDays.toInt()) + val cutoff = latestDate.minusDays(windowDays - 1) + val filtered = points.filter { (day, _) -> + runCatching { LocalDate.parse(day) }.getOrNull()?.let { !it.isBefore(cutoff) } ?: false + } + return filtered.ifEmpty { points.takeLast(windowDays.toInt()) } +}