diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
index c76e2c8ed9..d746e96cfd 100644
--- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt
+++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt
@@ -1944,18 +1944,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
rescoreAfterEdit()
}
- /** Re-open one deliberately deleted sleep window (#515): remove its durable tombstone first, then
- * immediately run the normal detector/scorer over the stored raw data. Unlike optimistic edits, this
- * returns false when the marker could not be removed — rescoring while it is still present would keep
- * suppressing the night and make a successful-looking button a no-op. */
- suspend fun recomputeDeletedSleep(marker: com.noop.data.DismissedSleep): Boolean {
- val cleared = runCatching {
- repository.allowSleepReDetection(marker.deviceId, marker.startTs)
- }.isSuccess
- if (!cleared) return false
- rescoreAfterEdit()
- return true
- }
+ /** Clear a deleted window's marker, then request the normal recent scan. Success confirms only
+ * marker removal, not that this particular night was scanned or restored (Refs #515). */
+ suspend fun recomputeDeletedSleep(marker: com.noop.data.DismissedSleep): Boolean =
+ reopenDeletedSleepWindow(
+ clearMarker = { repository.allowSleepReDetection(marker.deviceId, marker.startTs) },
+ scanRecent = { rescoreAfterEdit() },
+ )
/** Hide an unwanted deleted-sleep row from the persistent recompute card while preserving its
* detector tombstone. This is deliberately separate from [recomputeDeletedSleep], which removes the
diff --git a/android/app/src/main/java/com/noop/ui/DeletedSleepRecompute.kt b/android/app/src/main/java/com/noop/ui/DeletedSleepRecompute.kt
new file mode 100644
index 0000000000..5bdb8b1679
--- /dev/null
+++ b/android/app/src/main/java/com/noop/ui/DeletedSleepRecompute.kt
@@ -0,0 +1,22 @@
+package com.noop.ui
+
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Reopen a deleted sleep window before requesting the normal recent scan (Refs #515).
+ *
+ * Success means only that marker removal completed: the scan may not cover an old night,
+ * may lack raw samples, or may fail. Cancellation must stop the action at either stage.
+ */
+internal suspend fun reopenDeletedSleepWindow(
+ clearMarker: suspend () -> Unit,
+ scanRecent: suspend () -> Unit,
+): Boolean {
+ val cleared = runCatching { clearMarker() }
+ .onFailure { if (it is CancellationException) throw it }
+ .isSuccess
+ if (!cleared) return false
+ runCatching { scanRecent() }
+ .onFailure { if (it is CancellationException) throw it }
+ return true
+}
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 5dbc199f6e..7ef6f9f452 100644
--- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt
@@ -674,7 +674,7 @@ fun SleepScreen(
Toast.makeText(
context,
if (cleared) {
- uiString(R.string.l10n_sleep_screen_sleep_detection_reran_using_the_data_01757aad)
+ uiString(R.string.l10n_sleep_screen_deletion_marker_cleared_fc781c48)
} else {
uiString(R.string.l10n_sleep_screen_couldn_t_reopen_this_night_try_88265690)
},
@@ -1153,7 +1153,7 @@ private fun DeletedSleepWindowsCard(
color = Palette.textPrimary,
)
Text(
- uiString(R.string.l10n_sleep_screen_recompute_a_night_to_clear_its_fd9e15c3),
+ uiString(R.string.l10n_sleep_screen_recompute_clears_the_deletion_marker_and_a8922cbb),
style = NoopType.footnote,
color = Palette.textSecondary,
)
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 8399bc377c..14d1a55162 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -1670,12 +1670,12 @@
Keine Phasendaten für diese Nacht aufgezeichnet.
Offenes Journal
Vorherige Nacht
- Berechne eine Nacht neu, um ihre Löschmarkierung zu entfernen und die verfügbaren Rohdaten erneut zu prüfen.
+ Neu berechnen entfernt die Löschmarkierung und prüft die letzten 21 Tage.
Diese gelöschte Schlafaufzeichnung neu berechnen
Diese Nacht neu berechnen
Wird neu berechnet…
Schlaf
- Die Schlaferkennung wurde mit den für diese Nacht verfügbaren Daten erneut ausgeführt.
+ Löschmarkierung entfernt.
Phasen-Aufschlüsselung
Dies bewegt die Nacht zu einer Zeit ohne aufgezeichnete Daten. Stufen können dort nicht abgeleitet werden, so dass sie als leer angezeigt werden können, bis die Daten sie abdecken.
Deine nachtdaten sind in. Protokolliere, wie du dich gefühlt hast, hilft NOOP zu lernen, was deine beste Genesung antreibt.
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 20aed28131..ab7b1af50d 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -1470,12 +1470,12 @@
No se registraron datos de fases para esta noche.
Open Journal
Noche anterior
- Vuelve a calcular una noche para borrar su marcador de eliminación y analizar de nuevo los datos sin procesar disponibles.
+ Recalcular elimina la marca de borrado y analiza los últimos 21 días.
Volver a calcular esta noche de sueño eliminada
Volver a calcular esta noche
Recalculando…
Sueño
- La detección del sueño se volvió a ejecutar con los datos disponibles para esta noche.
+ Marca de borrado eliminada.
Desglose de fases
Esto mueve la noche a un tiempo sin datos registrados. Las etapas no se pueden derivar allí, por lo que puede mostrar como vacía hasta que los datos lo cubren.
Tus datos nocturnos están dentro. Logging how you felt helps NOOP learn what drives your best recovery.
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index bd1c4cc3b1..63a380ad4d 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -1488,12 +1488,12 @@
Aucune donnée de stade enregistrée pour cette nuit.
Journal ouvert
Nuit précédente
- Recalculez une nuit pour effacer son marqueur de suppression et analyser à nouveau les données brutes disponibles.
+ Recalculer efface le marqueur de suppression et analyse les 21 derniers jours.
Recalculer cette nuit de sommeil supprimée
Recalculer cette nuit
Recalcul en cours…
Sommeil
- La détection du sommeil a été relancée avec les données disponibles pour cette nuit.
+ Marqueur de suppression effacé.
Répartition des stades
Cela déplace la nuit à une heure sans données enregistrées. Les étapes ne peuvent pas être dérivées là, donc il peut apparaître comme vide jusqu\'à ce que les données le couvrent.
Vos données de nuit sont dedans. L\'exploitation de vos sentiments aide NOOP à apprendre ce qui motive votre meilleure récupération.
diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml
index 1a47561f99..be0b4dfa63 100644
--- a/android/app/src/main/res/values-it/strings.xml
+++ b/android/app/src/main/res/values-it/strings.xml
@@ -1744,12 +1744,12 @@
Nessun dato sulle fasi registrato per questa notte.
Apri Diario
Notte precedente
- Ricalcola una notte per rimuoverne il contrassegno di eliminazione e analizzare di nuovo i dati grezzi disponibili.
+ Ricalcola rimuove il contrassegno di eliminazione e analizza gli ultimi 21 giorni.
Ricalcola questa notte di sonno eliminata
Ricalcola questa notte
Ricalcolo…
Sonno
- Il rilevamento del sonno è stato rieseguito con i dati disponibili per questa notte.
+ Contrassegno di eliminazione rimosso.
Dettaglio fasi
Questo sposta la notte in un orario senza dati registrati. Lì non è possibile ricavare le fasi, quindi potrebbe risultare vuota finché non ci saranno dati.
I dati della tua notte sono arrivati. Registrare come ti sei sentito aiuta NOOP a capire cosa favorisce il tuo recupero migliore.
diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml
index b5e9c52de8..c29d5c8553 100644
--- a/android/app/src/main/res/values-pl/strings.xml
+++ b/android/app/src/main/res/values-pl/strings.xml
@@ -1627,12 +1627,12 @@
Brak danych scenicznych zarejestrowanych dla tej nocy.
Otwórz dziennik
Poprzednia noc
- Oblicz ponownie noc, aby usunąć znacznik usunięcia i ponownie przeskanuj dostępne surowe dane.
+ Ponowne obliczenie usuwa znacznik usunięcia i analizuje ostatnie 21 dni.
Oblicz ponownie tę usuniętą noc snu
Przelicz jeszcze raz tę noc
Przeliczam ponownie…
Sen
- Wykrywanie snu zostało ponownie przeprowadzone przy użyciu danych dostępnych dla tej nocy.
+ Znacznik usunięcia został usunięty.
Podział sceny
Spowoduje to przeniesienie nocy do czasu, w którym nie ma zarejestrowanych danych. Nie można tam wyprowadzić etapów, więc może być wyświetlany jako pusty, dopóki nie pokryją go dane.
Dane z Twojej nocy są już dostępne. Rejestrowanie Twojego samopoczucia pomaga NOOP dowiedzieć się, co zapewnia najlepszą regenerację.
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index b64877aeb3..7300363e2f 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -1633,12 +1633,12 @@
Sem dados de palco registados para esta noite.
Diário aberto
Noite anterior
- Recalcule uma noite para limpar o teu marcador de eliminação e verifique novamente os dados brutos disponíveis.
+ Recalcular remove a marca de eliminação e analisa os últimos 21 dias.
Recalcule esta noite de sono excluída
Recalcular esta noite
Recalculando…
Sono
- A deteção do sono foi novamente executada utilizando os dados disponíveis para esta noite.
+ Marca de eliminação removida.
Análise do estágio
Isto move a noite para um horário sem dados registados. Os estágios não podem ser derivados aí, pelo que pode aparecer como vazio até que os dados o cubram.
Os teus dados noturnos estão disponíveis. Registar como se sentiu ajuda o NOOP a aprender o que impulsiona a tua melhor recuperação.
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 6fbec7268f..3752abfe5f 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -1688,12 +1688,12 @@
Данные о стадиях сна за эту ночь не записаны.
Открыть Журнал
Предыдущая ночь
- Пересчитайте ночь, чтобы снять метку удаления и заново просмотреть доступные сырые данные.
+ Пересчёт снимает отметку об удалении и анализирует последние 21 день.
Пересчитать эту удалённую ночь сна
Пересчитать эту ночь
Пересчёт…
Сон
- Определение сна выполнено заново по данным, доступным за эту ночь.
+ Отметка об удалении снята.
Структура стадий
Это перенесёт ночь на время, где нет записанных данных. Стадии там вывести нельзя, поэтому она может отображаться пустой, пока данные её не покроют.
Данные за ночь получены. Отметка о самочувствии помогает NOOP понять, что даёт вам лучшее восстановление.
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 75a9e8a04f..4f2107f24c 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -1609,12 +1609,12 @@
该晚未记录到任何睡眠分期数据。
打开日记
上一晚
- 重新计算某一晚,即可清除它的删除标记,并利用现存的原始数据重新进行睡眠分析。
+ 重新计算会清除删除标记,并分析最近 21 天的数据。
重新计算并恢复这个被删除的夜晚
重新计算本晚数据
正在重新计算…
睡眠
- 已使用本晚所有可用的原始数据重新运行了睡眠检测算法。
+ 已清除删除标记。
各睡眠阶段拆解
这会将该晚睡眠强行移动到一个没有任何原始记录数据的时间段。由于缺失基础数据,NOOP 无法推导出睡眠分期,因此它会一直显示为空,直到您同步了补全该时段的手环数据。
您的夜间数据已就绪。每天记录下您的主观感受,能帮助 NOOP 分析出什么样的习惯最能促进您的身体恢复。
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 5ae979343a..e8bd24e0ce 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1775,12 +1775,12 @@
No stage data recorded for this night.
Open Journal
Previous night
- Recompute a night to clear its deletion marker and scan the available raw data again.
+ Recompute clears the deletion marker and scans the most recent 21 days.
Recompute this deleted sleep night
Recompute this night
Recomputing…
Sleep
- Sleep detection reran using the data available for this night.
+ Deletion marker cleared.
Stage breakdown
This moves the night to a time with no recorded data. Stages can\'t be derived there, so it may show as empty until data covers it.
Your night data is in. Logging how you felt helps NOOP learn what drives your best recovery.
diff --git a/android/app/src/test/java/com/noop/ui/SleepEditRescoreTest.kt b/android/app/src/test/java/com/noop/ui/SleepEditRescoreTest.kt
index aabaaf9352..2dc07b84a6 100644
--- a/android/app/src/test/java/com/noop/ui/SleepEditRescoreTest.kt
+++ b/android/app/src/test/java/com/noop/ui/SleepEditRescoreTest.kt
@@ -23,7 +23,10 @@ import org.junit.Test
* resolveTodayRow): persist-then-rescore, best-effort persist, best-effort rescore, and cancellation
* propagation (the #125 rule the 15-min loop already follows).
*
- * The exact shapes mirrored from AppViewModel:
+ * Deleted-window cases call the production `reopenDeletedSleepWindow` used by AppViewModel.
+ * The remaining edit cases retain the existing mirrors below.
+ *
+ * The exact edit shapes mirrored from AppViewModel:
*
* suspend fun updateSleepSessionTimes(...) { runCatching { repository.updateSleepSessionTimes(...) }; rescoreAfterSleepEdit() }
* suspend fun deleteSleepSession(...) { runCatching { repository.deleteSleepSession(...) }; rescoreAfterSleepEdit() }
@@ -55,17 +58,6 @@ class SleepEditRescoreTest {
rescoreAfterSleepEdit(analyzeRecent)
}
- /** Pure mirror of recomputeDeletedSleep: the marker must be cleared before analysis, and a failed
- * clear must not run a detector that would still suppress the same night. */
- private suspend fun recomputeDeletedSleep(
- clearMarker: suspend () -> Unit,
- analyzeRecent: suspend () -> Unit,
- ): Boolean {
- if (runCatching { clearMarker() }.isFailure) return false
- rescoreAfterSleepEdit(analyzeRecent)
- return true
- }
-
@Test
fun rescoreRunsAfterAPersist() = runTest {
val rec = Recorder()
@@ -115,9 +107,9 @@ class SleepEditRescoreTest {
@Test
fun deletedSleepMarkerIsClearedBeforeRedetection() = runTest {
val rec = Recorder()
- val accepted = recomputeDeletedSleep(
+ val accepted = reopenDeletedSleepWindow(
clearMarker = { rec.events += "clear" },
- analyzeRecent = { rec.events += "redetect" },
+ scanRecent = { rec.events += "redetect" },
)
assertTrue(accepted)
assertEquals(listOf("clear", "redetect"), rec.events)
@@ -126,11 +118,49 @@ class SleepEditRescoreTest {
@Test
fun failedMarkerClearDoesNotRunRedetection() = runTest {
val rec = Recorder()
- val accepted = recomputeDeletedSleep(
+ val accepted = reopenDeletedSleepWindow(
clearMarker = { throw IllegalStateException("DB write failed") },
- analyzeRecent = { rec.events += "redetect" },
+ scanRecent = { rec.events += "redetect" },
)
assertTrue(!accepted)
assertTrue(rec.events.isEmpty())
}
+
+ @Test
+ fun markerClearStillSucceedsWhenRecentScanFails() = runTest {
+ assertTrue(reopenDeletedSleepWindow(
+ clearMarker = {},
+ scanRecent = { throw IllegalStateException("no scan completed") },
+ ))
+ }
+
+ @Test
+ fun cancellingMarkerClearDoesNotRequestScan() = runTest {
+ val rec = Recorder()
+ try {
+ reopenDeletedSleepWindow(
+ clearMarker = { throw CancellationException("clear cancelled") },
+ scanRecent = { rec.events += "scan" },
+ )
+ fail("marker cancellation must propagate")
+ } catch (e: CancellationException) {
+ assertEquals("clear cancelled", e.message)
+ }
+ assertTrue(rec.events.isEmpty())
+ }
+
+ @Test
+ fun cancellingRecentScanPropagatesAfterMarkerClear() = runTest {
+ val rec = Recorder()
+ try {
+ reopenDeletedSleepWindow(
+ clearMarker = { rec.events += "clear" },
+ scanRecent = { throw CancellationException("scan cancelled") },
+ )
+ fail("scan cancellation must propagate")
+ } catch (e: CancellationException) {
+ assertEquals("scan cancelled", e.message)
+ }
+ assertEquals(listOf("clear"), rec.events)
+ }
}