Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"sourceLanguage": "en",
"strings": {
"Sleep result updated": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Sleep result updated"}}, "de": {"stringUnit": {"state": "translated", "value": "Schlafergebnis aktualisiert"}}, "es": {"stringUnit": {"state": "translated", "value": "Resultado del sueño actualizado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Résultat du sommeil mis à jour"}}, "it": {"stringUnit": {"state": "translated", "value": "Risultato del sonno aggiornato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaktualizowano wynik snu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Resultado do sono atualizado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Результат сна обновлён"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡眠结果已更新"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡眠結果已更新"}}}},
"This night's sleep times or total asleep changed by at least five minutes.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "This night's sleep times or total asleep changed by at least five minutes."}}, "de": {"stringUnit": {"state": "translated", "value": "Die Schlafzeiten oder die Gesamtschlafdauer dieser Nacht haben sich um mindestens fünf Minuten geändert."}}, "es": {"stringUnit": {"state": "translated", "value": "Las horas de sueño o el tiempo total dormido de esta noche cambiaron al menos cinco minutos."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les horaires de sommeil ou la durée totale de sommeil de cette nuit ont changé d’au moins cinq minutes."}}, "it": {"stringUnit": {"state": "translated", "value": "Gli orari del sonno o il tempo totale dormito di questa notte sono cambiati di almeno cinque minuti."}}, "pl": {"stringUnit": {"state": "translated", "value": "Godziny snu lub łączny czas snu tej nocy zmieniły się o co najmniej pięć minut."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os horários de sono ou o tempo total de sono desta noite mudaram pelo menos cinco minutos."}}, "ru": {"stringUnit": {"state": "translated", "value": "Время засыпания, пробуждения или общая продолжительность сна этой ночи изменились как минимум на пять минут."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这晚的入睡时间、醒来时间或总睡眠时长变化了至少五分钟。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這晚的入睡時間、醒來時間或總睡眠時長變化了至少五分鐘。"}}}},

"Experimental · Oura ring all-day heart rate": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Experimentell · Oura-Ring: Herzfrequenz den ganzen Tag"}},
"es": {"stringUnit": {"state": "translated", "value": "Experimental · Anillo Oura: frecuencia cardiaca todo el día"}},
Expand Down
31 changes: 31 additions & 0 deletions Strand/Screens/SleepResultChange.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// Presentation-only comparison of a fully loaded, displayed night. Five minutes is a
/// notification threshold, not an accuracy claim. Stage redistribution alone stays quiet.
/// Keep the baseline across smaller changes so cumulative drift can cross the threshold.
struct SleepResultSnapshot: Equatable {
let scope: String
let onset: Int
let wake: Int
let asleepMinutes: Double
let edited: Bool
}

struct SleepResultChangeTracker {
private var baseline: SleepResultSnapshot?

mutating func observe(_ next: SleepResultSnapshot?, ready: Bool) -> Bool {
guard ready else { return false }
guard let next else { baseline = nil; return false }
guard let previous = baseline, previous.scope == next.scope,
!previous.edited, !next.edited else {
baseline = next
return false
}
let changed = abs(next.onset - previous.onset) >= 300
|| abs(next.wake - previous.wake) >= 300
|| abs(next.asleepMinutes - previous.asleepMinutes) >= 5
if changed { baseline = next }
return changed
}
}
63 changes: 60 additions & 3 deletions Strand/Screens/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ struct SleepView: View {
/// The repo signature the cached `model` was built from. Cheap to compute every render;
/// when it differs from the current inputs we rebuild the model.
@State private var modelKey: SleepInputKey?
@State private var loadedSleepRefresh: Int?
@State private var resultTracker = SleepResultChangeTracker()
@State private var resultNoticeVisible = false
@State private var resultNoticeRevision = 0
@State private var resultNoticeScope: String?

/// Which night the hero hypnogram shows: 0 = last night, N = N sleep-sessions back.
/// Snaps back to 0 whenever the data key changes — a stale offset would silently point
Expand Down Expand Up @@ -165,6 +170,11 @@ struct SleepView: View {
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
if let sleepUndo { sleepUndoBanner(sleepUndo) }
SleepFreshnessNote(latestWakeTs: resolved.night.session.endTs)
if resultNoticeVisible {
DataPendingNote(title: "Sleep result updated",
message: "This night's sleep times or total asleep changed by at least five minutes.",
symbol: "checkmark.circle")
}
// Bleed past ScreenScaffold's 16/24 gutters so the hero column is edge-to-edge
// in the upper band; the night scene itself is the fixed topBackground.
// Customize sits at the end of the hero (not floating in a blank band).
Expand Down Expand Up @@ -201,6 +211,8 @@ struct SleepView: View {
// `decodedNight` JSON-decodes and body re-evaluates at 1Hz while HR streams. (#160)
.onChangeCompat(of: nightOffset) { newOffset in
navNight = newOffset == 0 ? nil : decodedNight(at: newOffset)
resetResultNotice()
observeResultChange()
}
.onAppear {
if modelKey != key {
Expand All @@ -210,24 +222,39 @@ struct SleepView: View {
navNight = nil
}
}
.onChangeCompat(of: intelligence.computing) { _ in observeResultChange() }
.onDisappear { resetResultNotice() }
.task(id: resultNoticeRevision) {
guard resultNoticeVisible else { return }
do { try await Task.sleep(nanoseconds: 8_000_000_000) }
catch { return }
resultNoticeVisible = false
}
// Load EVERY sleep block across BOTH sources (un-deduplicated) so the hero's ◀/▶ can
// browse split-sleep days the dashboard collapses — including Bluetooth-only nights,
// whose blocks live under the computed source. Re-runs whenever a sync/import bumps
// refreshSeq; snaps back to the newest day and rebuilds the model so offset 0 reflects
// the freshly-loaded blocks. (#170)
.task(id: repo.refreshSeq) {
allSessions = await repo.allSleepSessions()
let refresh = repo.refreshSeq
let sessions = await repo.allSleepSessions()
// Load the learned habitual midsleep the engine used, so the main-night pick aligns to it
// (a shift/late sleeper) instead of only the cold-start band. nil under threshold. (#547)
habitualMidsleepSec = await repo.habitualMidsleepSec()
let habitual = await repo.habitualMidsleepSec()
// Per-epoch motion for every block (#407), keyed by detected start. mergeDay reads only the
// already-resolved group's entries — this just pre-fetches them all so the model build is sync.
motionByStart = await repo.sessionMotions(sessions: allSessions)
let motions = await repo.sessionMotions(sessions: sessions)
guard !Task.isCancelled, refresh == repo.refreshSeq else { return }
allSessions = sessions
habitualMidsleepSec = habitual
motionByStart = motions
nightOffset = 0
navNight = nil
modelKey = dataKey
navDaysCache = SleepModel.navDays(navSessions: navSessions)
model = buildModel()
loadedSleepRefresh = refresh
observeResultChange()
}
.sheet(item: $wakeEdit) { edit in
// The night's RECORDED coverage for the #940 guards: from the immutable detected
Expand Down Expand Up @@ -1806,6 +1833,36 @@ struct SleepView: View {
typicalTotalMin: model.typicalTotalMin))
}

/// Compare only after the screen has loaded the refreshed blocks and scoring has settled.
/// The observer lives on the screen, so a hidden notice cannot stop change observation.
private func observeResultChange() {
guard loadedSleepRefresh == repo.refreshSeq, !intelligence.computing else { return }
guard let model else { resetResultNotice(); return }
let snapshot = resultSnapshot(heroNight(model))
if resultNoticeScope != snapshot.scope { resultNoticeVisible = false }
resultNoticeScope = snapshot.scope
if resultTracker.observe(snapshot, ready: true) {
resultNoticeVisible = true
resultNoticeRevision += 1
}
}

private func resetResultNotice() {
resultTracker = SleepResultChangeTracker()
resultNoticeVisible = false
resultNoticeScope = nil
}

private func resultSnapshot(_ night: Night) -> SleepResultSnapshot {
let day = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: Double(night.session.endTs)))
let sources = Set(night.sourceBlocks.compactMap(\.deviceId)).sorted().joined(separator: ",")
return SleepResultSnapshot(
scope: "\(repo.deviceId):\(sources):\(day.timeIntervalSince1970)",
onset: night.session.effectiveStartTs, wake: night.session.endTs,
asleepMinutes: night.stages.asleep,
edited: night.session.userEdited || night.sourceBlocks.contains { $0.userEdited })
}

// MARK: - Memoization plumbing

/// A cheap fingerprint of the repo inputs this screen derives from. Recomputed every
Expand Down
34 changes: 34 additions & 0 deletions StrandTests/SleepResultChangeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import XCTest
@testable import Strand

final class SleepResultChangeTests: XCTestCase {
func testResultChangeOracle() {
var tracker = SleepResultChangeTracker()
func sample(_ scope: String = "strap:source:night", _ onset: Int = 1000,
_ wake: Int = 30000, _ asleep: Double = 420, _ edited: Bool = false) -> SleepResultSnapshot {
SleepResultSnapshot(scope: scope, onset: onset, wake: wake, asleepMinutes: asleep, edited: edited)
}
var actual: [Bool] = []
func observe(_ snapshot: SleepResultSnapshot?, _ ready: Bool = true) {
actual.append(tracker.observe(snapshot, ready: ready))
}
observe(sample()) // First display.
observe(sample()) // Unrelated pass, same displayed result.
observe(sample("strap:source:night", 1060, 30060, 421)) // One-minute shift.
observe(sample("strap:source:night", 1299, 30299, 424.99)) // Below threshold.
observe(sample("strap:source:night", 1300)) // Cumulative onset threshold.
observe(sample("strap:source:night", 1300)) // Same result does not re-announce.
observe(sample("strap:source:night", 1300, 30300), false) // In-progress write.
observe(sample("strap:source:night", 1300, 30300)) // Completed wake change.
observe(sample("strap:source:night", 1300, 30300, 425)) // Total asleep.
observe(sample("strap:source:night", 1300, 30300, 420)) // Decrease also matters.
observe(sample("other-night")) // Navigation/new night.
observe(sample("other-strap")) // Device/source change.
observe(sample("other-strap", 2000, 31000, 430, true)) // Manual edit.
observe(sample("other-strap", 3000, 32000, 440, true))
observe(nil) // No visible night resets baseline.
observe(sample())
XCTAssertEqual(actual, [false, false, false, false, true, false, false, true,
true, true, false, false, false, false, false, false])
}
}
33 changes: 33 additions & 0 deletions android/app/src/main/java/com/noop/ui/SleepResultChange.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.noop.ui

import kotlin.math.abs

/** Presentation-only twin of Swift SleepResultChangeTracker. Five minutes is a notification
* threshold, not an accuracy claim. Stage redistribution alone stays quiet; small changes
* accumulate against the last announced baseline. */
internal data class SleepResultSnapshot(
val scope: String,
val onset: Long,
val wake: Long,
val asleepMinutes: Double,
val edited: Boolean,
)

internal class SleepResultChangeTracker {
private var baseline: SleepResultSnapshot? = null

fun observe(next: SleepResultSnapshot?, ready: Boolean): Boolean {
if (!ready) return false
if (next == null) { baseline = null; return false }
val previous = baseline
if (previous == null || previous.scope != next.scope || previous.edited || next.edited) {
baseline = next
return false
}
val changed = abs(next.onset - previous.onset) >= 300L ||
abs(next.wake - previous.wake) >= 300L ||
abs(next.asleepMinutes - previous.asleepMinutes) >= 5.0
if (changed) baseline = next
return changed
}
}
50 changes: 46 additions & 4 deletions android/app/src/main/java/com/noop/ui/SleepScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.foundation.lazy.rememberLazyListState
import kotlinx.coroutines.ensureActive
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import com.noop.data.DailyMetric
import com.noop.data.HrBucket
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
Expand Down Expand Up @@ -234,6 +236,9 @@ fun SleepScreen(
// 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<List<SleepSession>>(emptyList()) }
var loadedSleepDays by remember { mutableStateOf<List<DailyMetric>?>(null) }
var loadedSleepStrap by remember { mutableStateOf<String?>(null) }
val sleepRowsReady = loadedSleepDays == days && loadedSleepStrap == vm.activeStrapId
// Durable deleted-night markers. Unlike the 7-second Undo banner these remain reachable after the
// session row is gone, giving each suppressed window a "Recompute this night" escape hatch (#515).
var dismissedSleeps by remember { mutableStateOf<List<DismissedSleep>>(emptyList()) }
Expand All @@ -243,17 +248,19 @@ fun SleepScreen(
// `sleeps` in place WITHOUT touching `days`, so it must not reset the browse — keeping the
// user on the night they just edited. (#160)
var nightOffset by remember { mutableIntStateOf(0) }
LaunchedEffect(days) {
sleeps = runCatching {
LaunchedEffect(days, vm.activeStrapId) {
loadedSleepDays = null
val strap = vm.activeStrapId
val loaded = runCatching {
val now = System.currentTimeMillis() / 1000L
// Read the ACTIVE-strap ∪ canonical "my-whoop" union (#814/#1008), not the canonical id
// alone: after a strap remove+re-add live nights land under the fresh "whoop-<uuid>" id, so
// a canonical-only read left this screen STUCK on the last pre-re-add night while every
// union-joined surface moved on (the #1014/#1009 stuck-sleep divergence, in the OTHER
// direction). Exact-duplicate (startTs, endTs) blocks recorded under both ids are dropped;
// naps/split blocks survive. Single-device installs collapse to one id, byte-identical.
val imported = vm.repo.sleepSessionsUnion(vm.activeStrapId, 0L, now)
val computed = vm.repo.computedSleepSessionsUnion(vm.activeStrapId, 0L, now)
val imported = vm.repo.sleepSessionsUnion(strap, 0L, now)
val computed = vm.repo.computedSleepSessionsUnion(strap, 0L, now)
// Key by the LOCAL wake-day (#304), matching WhoopRepository.mergeSleep — a UTC key
// mis-attributed a UTC+ user's early-morning wake to yesterday. REUSE the existing
// dayString(ts, offsetSec) overload; do not add a new one (it clashes on the JVM).
Expand All @@ -268,7 +275,11 @@ fun SleepScreen(
WhoopRepository.mergeSleepRichness(imported, computed) { localEndDay(it.endTs) }
.sortedBy { it.effectiveStartTs }
}.getOrDefault(emptyList())
kotlinx.coroutines.currentCoroutineContext().ensureActive()
sleeps = loaded
nightOffset = 0
loadedSleepDays = days
loadedSleepStrap = strap
}

// Read the active∪canonical management union so a marker created before a strap re-add remains
Expand Down Expand Up @@ -531,6 +542,29 @@ fun SleepScreen(
}
val display = remember(model, night) { heroDisplay(model, night) }

val resultSnapshot = if (night != null && display != null) SleepResultSnapshot(
scope = "${vm.activeStrapId}:${(night.heroGroup.ifEmpty { listOf(night.session) }).map { it.deviceId }.distinct().sorted().joinToString(",")}:${night.dayKey}",
onset = night.heroOnsetTs ?: night.session.effectiveStartTs,
wake = night.heroWakeTs ?: night.session.endTs,
asleepMinutes = display.stages.asleep,
edited = night.session.userEdited || night.heroGroup.any { it.userEdited },
) else null
val changeTracker = remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { SleepResultChangeTracker() }
var resultChanged by remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { mutableStateOf(false) }
var resultRevision by remember(vm.activeStrapId, nightOffset, resultSnapshot?.scope) { mutableIntStateOf(0) }
LaunchedEffect(resultSnapshot, sleepRowsReady, freshnessLive.analyzing, changeTracker) {
if (changeTracker.observe(resultSnapshot, sleepRowsReady && !freshnessLive.analyzing)) {
resultChanged = true
resultRevision++
}
}
LaunchedEffect(resultRevision, changeTracker) {
if (resultChanged) {
kotlinx.coroutines.delay(8_000)
resultChanged = false
}
}

val sleepFreshness = remember(sleeps, freshnessLive) {
val zone = ZoneId.systemDefault()
val now = Instant.now().atZone(zone)
Expand Down Expand Up @@ -651,6 +685,14 @@ fun SleepScreen(
)
}
}
if (resultChanged) {
item {
DataPendingNote(
title = stringResource(R.string.sleep_result_updated_title),
body = stringResource(R.string.sleep_result_updated_message),
)
}
}
sleepFreshness?.let { status ->
item { SleepFreshnessNote(status, backfillNote ?: 0) }
}
Expand Down
Loading
Loading