diff --git a/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt index 3d2e3f5..7b2dfac 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/notifications/NotificationActionHandler.kt @@ -5,6 +5,7 @@ import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.ReminderAction import dev.privacyllc.period.core.notifications.ReminderActionRequest import dev.privacyllc.period.core.notifications.ReminderActionRules +import dev.privacyllc.period.core.notifications.ReminderNotifier import dev.privacyllc.period.domain.cycle.PeriodRecordSource import kotlinx.coroutines.flow.first import java.time.Clock @@ -32,6 +33,7 @@ class NotificationActionHandler @Inject constructor( private val repository: CycleRepository, private val preferences: UserPreferencesRepository, private val clock: Clock, + private val notifier: ReminderNotifier, ) { /** @@ -47,6 +49,11 @@ class NotificationActionHandler @Inject constructor( val today = LocalDate.now(clock) val periods = repository.confirmedPeriods.first() + // Answered, so it comes out of the shade whatever the verdict below is — + // including "too old to write down", where leaving it would invite the + // same tap tomorrow. Cancelling is idempotent and touches no health data. + notifier.cancel() + return when (val verdict = ReminderActionRules.verdict(request, today, periods)) { // Too old, already answered, or nothing left to close. The tap has // opened the app, which is where she can see what is recorded and diff --git a/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt b/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt index cd295c8..3728fc5 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/notifications/ReminderCoordinator.kt @@ -4,11 +4,13 @@ import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.core.datastore.UserPreferences import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.ReminderScheduler +import dev.privacyllc.period.core.notifications.ReminderNotifier import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -36,6 +38,7 @@ class ReminderCoordinator @Inject constructor( private val repository: CycleRepository, private val preferences: UserPreferencesRepository, private val scheduler: ReminderScheduler, + private val notifier: ReminderNotifier, ) { /** @@ -60,8 +63,34 @@ class ReminderCoordinator @Inject constructor( forecastDates = repository.forecast.map { it?.mostLikelyStartDate }, preferences = preferences.preferences, ).launchIn(scope) + + dismissAnswered( + answers = repository.confirmedPeriods.map { periods -> + periods.maxByOrNull { it.startDate }?.let { it.startDate to it.endDate } + }, + ).launchIn(scope) } + /** + * A question answered in the app takes the notification with it. + * + * `setAutoCancel` only fires when the body is tapped, so a reminder that was + * answered — by a button, or by opening the app and logging it there — stayed + * in the shade asking something that had been settled, and could be answered + * a second time. + * + * `drop(1)` is the load-bearing part. This runs in every process, including + * the one WorkManager starts to run the worker, and the first emission is + * simply the current state rather than an answer. Without it the worker's own + * process would cancel the notification it was about to post. + */ + internal fun dismissAnswered(answers: Flow?>): Flow<*> = + answers + .distinctUntilChanged() + .drop(1) + .onEach { notifier.cancel() } + .catch { } + /** * The clock or the time zone moved, so the schedule has to be re-aimed. * diff --git a/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt b/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt index 0e3df60..98d328a 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTest.kt @@ -16,7 +16,9 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -49,6 +51,18 @@ import java.time.ZoneOffset @Config(sdk = [34]) class NotificationActionHandlerTest { + /** Dismissing is not a health write; these tests are about the writes. */ + private class NoOpNotifier : dev.privacyllc.period.core.notifications.ReminderNotifier { + override fun notify( + text: dev.privacyllc.period.core.notifications.NotificationText, + privacy: NotificationPrivacy, + contentIntent: android.app.PendingIntent?, + actions: List, + ) = true + override fun cancel() = Unit + } + + @get:Rule val temp = TemporaryFolder() private lateinit var repo: CycleRepository @@ -63,13 +77,22 @@ class NotificationActionHandlerTest { // cannot name PeriodDatabase, which is the boundary architecture/README // describes and the compiler enforces. repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock) - runBlocking { repo.deleteAllHealthData() } + // Wait for the flow to actually reflect the wipe. The database is a real + // file shared by every test in this class, and a delete that has not yet + // reached the observer makes the NEXT test read the previous one's + // period — which looks exactly like the staleness rule failing. + runBlocking { + repo.deleteAllHealthData() + withTimeout(10_000) { + while (repo.confirmedPeriods.first().isNotEmpty()) delay(10) + } + } prefs = UserPreferencesRepository( PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.Unconfined)) { temp.newFile("prefs.preferences_pb") }, ) - handler = NotificationActionHandler(repo, prefs, clock) + handler = NotificationActionHandler(repo, prefs, clock, NoOpNotifier()) } // ----------------------------------------------------------------------- diff --git a/app/src/test/kotlin/dev/privacyllc/period/notifications/ReminderCoordinatorTest.kt b/app/src/test/kotlin/dev/privacyllc/period/notifications/ReminderCoordinatorTest.kt index 3341abb..4549126 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/notifications/ReminderCoordinatorTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/notifications/ReminderCoordinatorTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ApplicationProvider import dev.privacyllc.period.core.data.CycleData import dev.privacyllc.period.core.datastore.UserPreferences import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.ReminderNotifier import dev.privacyllc.period.core.notifications.ReminderScheduler import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import kotlinx.coroutines.CoroutineScope @@ -47,12 +48,26 @@ class ReminderCoordinatorTest { override fun cancel() = Unit } + /** Counts dismissals; posting is the worker's business, not the coordinator's. */ + private class FakeNotifier : ReminderNotifier { + var cancels = 0 + override fun notify( + text: dev.privacyllc.period.core.notifications.NotificationText, + privacy: dev.privacyllc.period.core.datastore.NotificationPrivacy, + contentIntent: android.app.PendingIntent?, + actions: List, + ) = true + override fun cancel() { cancels++ } + } + private lateinit var coordinator: ReminderCoordinator private lateinit var preferences: UserPreferencesRepository + private lateinit var notifier: FakeNotifier @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + notifier = FakeNotifier() preferences = UserPreferencesRepository( PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("coordinator_test") }, ) @@ -60,6 +75,7 @@ class ReminderCoordinatorTest { repository = CycleData.repository(context, PersonalPredictionEngine(), Clock.systemUTC()), preferences = preferences, scheduler = FakeScheduler(), + notifier = notifier, ) } @@ -73,6 +89,7 @@ class ReminderCoordinatorTest { coordinator.scheduleUpdates( forecastDates = flow { throw IllegalArgumentException("a prediction invariant failed") }, preferences = flowOf(UserPreferences.Defaults), + clockGeneration = flowOf(0), ).collect { } } @@ -80,6 +97,7 @@ class ReminderCoordinatorTest { coordinator.scheduleUpdates( forecastDates = flowOf(null), preferences = flow { throw IllegalStateException("the preferences file is unreadable") }, + clockGeneration = flowOf(0), ).collect { } } @@ -96,6 +114,11 @@ class ReminderCoordinatorTest { coordinator.scheduleUpdates( forecastDates = flowOf(null, null), preferences = flowOf(UserPreferences.Defaults), + // Finite, or this never completes. The real generation flow is a + // MutableStateFlow that stays open for the life of the process — + // correct there, and a hang here. It hung the whole app suite for an + // hour before a thread dump named this line. + clockGeneration = flowOf(0), ).collect { emissions++ } assertEquals("distinctUntilChanged should collapse the repeated value", 1, emissions) } @@ -123,4 +146,30 @@ class ReminderCoordinatorTest { assertEquals(2, preferences.checkInTally.first().countFor(7L)) } + + /** + * The first emission is the current state, not an answer. + * + * This chain runs in every process, including the one WorkManager starts to + * run the worker — so without the drop, the worker's own process would + * cancel the notification it was about to post. + */ + @Test fun `the state at start does not dismiss anything`() = runBlocking { + coordinator.dismissAnswered( + answers = flowOf(java.time.LocalDate.of(2026, 8, 20) to null), + ).collect { } + + assertEquals(0, notifier.cancels) + } + + @Test fun `an answer after start dismisses the reminder`() = runBlocking { + coordinator.dismissAnswered( + answers = flowOf( + java.time.LocalDate.of(2026, 8, 20) to null, + java.time.LocalDate.of(2026, 8, 20) to java.time.LocalDate.of(2026, 8, 24), + ), + ).collect { } + + assertEquals(1, notifier.cancels) + } } diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt index bf5cda5..e9402c5 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/NotificationCopy.kt @@ -112,6 +112,33 @@ object NotificationCopy { } } + /** + * "We'll stop checking for now" — §30's sentiment, and the only notification + * the app posts to say it is about to be quiet. + * + * It lived inline in the worker, which put it outside every test in this + * file: `NotificationCopyTest` and `NotificationPrivacyTest` both iterate + * `ReminderKind`, and this is not one. It also hardcoded PRIVATE visibility, + * so a user who chose Direct — having asked, explicitly, to be told plainly — + * got less than the mode promises. + * + * Not a [ReminderKind]: those are the six toggles, one per switch on the + * settings screen, and `toReminderPreferences` maps exactly six. Adding a + * seventh for a message nobody can turn off would make that mapping a lie. + */ + fun stopAskingText(privacy: NotificationPrivacy): NotificationText { + val plain = privacy == NotificationPrivacy.DIRECT + return NotificationText( + publicTitle = if (plain) "We'll stop checking for now" else "Reminder", + publicBody = if (plain) "Log your period whenever it begins." else null, + privateTitle = "We'll stop checking for now", + privateBody = "Log your period whenever it begins.", + // Direct is the mode that asked to be spoken to plainly on a lock + // screen, and none of this text names anything anyway. + visibility = if (plain) LockScreenVisibility.PUBLIC else LockScreenVisibility.PRIVATE, + ) + } + /** * The buttons a reminder offers: what each says, and what each does. * diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt index b9728a1..3c3f5fa 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/PeriodNotifier.kt @@ -128,6 +128,10 @@ class PeriodNotifier(private val context: Context) : ReminderNotifier { return true } + override fun cancel() { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + fun hasPermission(): Boolean = android.os.Build.VERSION.SDK_INT < 33 || ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderNotifier.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderNotifier.kt index f679102..5d5bc2c 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderNotifier.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderNotifier.kt @@ -33,4 +33,14 @@ interface ReminderNotifier { contentIntent: android.app.PendingIntent?, actions: List = emptyList(), ): Boolean + + /** + * Take the reminder out of the shade. + * + * `setAutoCancel` only covers the body being tapped, so answering with a + * button left the notification sitting there to be answered again — and + * confirming in the app instead left it there too, still asking a question + * that had been settled. + */ + fun cancel() } diff --git a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt index 9277d67..d92e754 100644 --- a/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt +++ b/core/notifications/src/main/kotlin/dev/privacyllc/period/core/notifications/ReminderWorker.kt @@ -2,9 +2,12 @@ package dev.privacyllc.period.core.notifications import android.app.PendingIntent import android.content.Context +import java.io.IOException +import android.database.sqlite.SQLiteException import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -64,8 +67,8 @@ class ReminderWorker @AssistedInject constructor( } catch (failure: Exception) { // Deliberately not logged. §45 applies here as much as to the UI, // and the likeliest throw on this path is a `Prediction` invariant - // failure whose message is derived from a cycle date. - Result.success() + // whose message is derived from a cycle date. + resultFor(failure, runAttemptCount) } private suspend fun decideAndNotify(): Result { @@ -105,15 +108,7 @@ class ReminderWorker @AssistedInject constructor( // §30's exact sentiment, and it is the only notification in the // app that exists to say the app will now be quiet. val posted = notifier.notify( - text = NotificationText( - publicTitle = if (prefs.notificationPrivacy == - dev.privacyllc.period.core.datastore.NotificationPrivacy.DIRECT - ) "We'll stop checking for now" else "Reminder", - publicBody = null, - privateTitle = "We'll stop checking for now", - privateBody = "Log your period whenever it begins.", - visibility = LockScreenVisibility.PRIVATE, - ), + text = NotificationCopy.stopAskingText(prefs.notificationPrivacy), privacy = prefs.notificationPrivacy, contentIntent = openApp(), ) @@ -180,6 +175,48 @@ class ReminderWorker @AssistedInject constructor( ) } +/** + * What a failed wake-up should report. + * + * Extracted and internal so the taxonomy can be tested directly: the plumbing + * that would produce each throw is a Room handle or a corrupt file, and neither + * is worth constructing to assert a `when`. + * + * Every failure used to be `Result.success()`, so a repository that threw every + * day produced no reminders, no retries, no logs and nothing a user or a + * developer could tell apart from a quiet cycle. + * + * Note the parameter is `Exception`, not `Throwable`, and the caller catches the + * same: an `Error` — an OOM, a `StackOverflowError` — is not something to retry + * or to call a success, and WorkManager recording it as a failure is right. + */ +internal fun resultFor(failure: Exception, runAttemptCount: Int): ListenableWorker.Result = when (failure) { + // A file or a database that could not be read this minute may be readable in + // fifteen. Worth asking again, a few times. + is IOException, is SQLiteException -> + if (runAttemptCount < MAX_REMINDER_RETRIES) { + ListenableWorker.Result.retry() + } else { + // Past the limit, give up quietly: a reminder that finally posts + // after a day of retries is answering a question nobody is still + // asking. + ListenableWorker.Result.success() + } + + // A deterministic fault — most likely a `Prediction` invariant, which fails + // identically on every retry. Retrying would spin, and silence is already + // this worker's honest answer when it has nothing it can say. + else -> ListenableWorker.Result.success() +} + +/** + * How many times a transient failure is worth retrying. + * + * Three, with WorkManager's default backoff: enough to ride out a busy disk, and + * short of turning a persistent fault into an all-day retry loop. + */ +internal const val MAX_REMINDER_RETRIES = 3 + /** The six toggles, as the rules want them. */ fun UserPreferences.toReminderPreferences() = ReminderPreferences( periodApproaching = periodApproachingEnabled, diff --git a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt index 100f6ce..e355850 100644 --- a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt +++ b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/NotificationCopyTest.kt @@ -2,6 +2,7 @@ package dev.privacyllc.period.core.notifications import dev.privacyllc.period.core.datastore.NotificationPrivacy import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -155,4 +156,38 @@ class NotificationCopyTest { } } } + + // ----------------------------------------------------------------------- + // The one notification that says the app is going quiet + // ----------------------------------------------------------------------- + + @Test fun `the stop-asking notice leaks nothing outside Direct`() { + NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach { privacy -> + val text = NotificationCopy.stopAskingText(privacy) + val onLockScreen = listOfNotNull(text.publicTitle, text.publicBody).joinToString(" ").lowercase() + NotificationCopy.SENSITIVE_WORDS.forEach { + assertFalse("$privacy leaked \"$it\"", onLockScreen.contains(it)) + } + } + } + + @Test fun `the stop-asking notice speaks plainly only in Direct`() { + // It used to hardcode PRIVATE visibility, so a user who explicitly asked + // to be told plainly got less than the mode promises. + assertEquals( + LockScreenVisibility.PUBLIC, + NotificationCopy.stopAskingText(NotificationPrivacy.DIRECT).visibility, + ) + NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach { + assertEquals(LockScreenVisibility.PRIVATE, NotificationCopy.stopAskingText(it).visibility) + } + } + + @Test fun `the stop-asking notice always has something to show on a lock screen`() { + // The same rule every other kind is held to: a notification with no + // public version renders its private one, which is the leak. + NotificationPrivacy.entries.forEach { + assertTrue(NotificationCopy.stopAskingText(it).publicTitle.isNotBlank()) + } + } } diff --git a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderWorkerTest.kt b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderWorkerTest.kt index 370eec2..0df5b8c 100644 --- a/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderWorkerTest.kt +++ b/core/notifications/src/test/kotlin/dev/privacyllc/period/core/notifications/ReminderWorkerTest.kt @@ -47,6 +47,9 @@ class ReminderWorkerTest { private class FakeNotifier(var canPost: Boolean = true) : ReminderNotifier { val posted = mutableListOf() var lastActions: List = emptyList() + var cancels = 0 + + override fun cancel() { cancels++ } override fun notify( text: NotificationText, @@ -184,4 +187,29 @@ class ReminderWorkerTest { assertTrue(notifier.posted.isEmpty()) assertEquals(0, tally()) } + + // ----------------------------------------------------------------------- + // Failing in a way somebody could act on + // ----------------------------------------------------------------------- + + @Test fun `a transient failure asks to be retried, and eventually gives up`() { + // Every failure used to be a success: a repository throwing every day + // produced no reminders, no retries, no logs, and nothing a user or a + // developer could tell apart from a quiet cycle. + listOf(java.io.IOException("disk busy"), android.database.sqlite.SQLiteException("locked")).forEach { + assertEquals(ListenableWorker.Result.retry(), resultFor(it, runAttemptCount = 0)) + assertEquals(ListenableWorker.Result.retry(), resultFor(it, runAttemptCount = MAX_REMINDER_RETRIES - 1)) + + // Past the limit it stops: a reminder that finally posts after a day + // of retries is answering a question nobody is still asking. + assertEquals(ListenableWorker.Result.success(), resultFor(it, runAttemptCount = MAX_REMINDER_RETRIES)) + } + } + + @Test fun `a deterministic fault ends the run quietly rather than spinning`() { + // A Prediction invariant fails identically every time. + listOf(IllegalStateException("invariant"), IllegalArgumentException("window")).forEach { + assertEquals(ListenableWorker.Result.success(), resultFor(it, runAttemptCount = 0)) + } + } } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a86318d..3f48d9a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -157,6 +157,34 @@ health app a crash mid-write is adjacent to losing what the user just entered, and a message they can read beats a process that vanished. The message carries the exception *type* and never a record's contents — §45. +### A failed wake-up says which kind of failure it was + +Every throw in the reminder worker used to become `Result.success()`. A +repository failing every day produced no reminders, no retries, no logs — §45 +forbids them here — and nothing a user or a developer could tell apart from a +quiet cycle. + +`resultFor` splits the two kinds. A file or database that could not be read this +minute (`IOException`, `SQLiteException`) is worth asking again, three times, +with WorkManager's backoff; past that it gives up quietly, because a reminder +that finally posts after a day of retries is answering a question nobody is still +asking. Anything else — most likely a `Prediction` invariant — fails identically +on every retry, so retrying would spin and silence is the honest answer. + +The catch is `Exception`, not `Throwable`, deliberately: an OOM or a +`StackOverflowError` is neither retryable nor a success, and WorkManager +recording it as a failure is right. + +**An answered reminder leaves the shade.** `setAutoCancel` only covers the body +being tapped, so answering with a button left it sitting there to be answered +again, and confirming in the app left it asking a settled question. The handler +cancels on any verdict — including "too old to write down", where leaving it +would invite the same tap tomorrow — and the coordinator cancels when the history +changes underneath it. That chain drops its first emission: it runs in every +process, including the one WorkManager starts for the worker, and the first value +is the current state rather than an answer, so without the drop the worker's own +process would cancel the notification it was about to post. + ### An answer belongs to the day it was asked about A notification waits in the shade until somebody deals with it. The handler used diff --git a/docs/history/HISTORY.md b/docs/history/HISTORY.md index 265585c..ec44893 100644 --- a/docs/history/HISTORY.md +++ b/docs/history/HISTORY.md @@ -52,6 +52,7 @@ was true at the time" survives Y stopping being true. | 2026-08-18 | Four Gradle modules at the skeleton, not the seventeen the specification sketches | a module created before it has contents is a place for things to be put by accident; the rest arrive with the batch that needs them | | 2026-08-18 | Eight milestones opened at once, issues filed only under Batch 01 | the roadmap is genuinely known and worth being visible; the *work items* are not, and inventing them would make every tracker percentage permanently wrong | | 2026-08-18 | OPERATIONS.md deleted rather than kept empty | this is an offline app, not a deployed service; an empty runbook reads as one nobody wrote | +| 2026-08-21 | Spotting is recorded, never predicted | §25 deliberately keeps spotting from resetting or driving the cycle, so predicting it would give it influence the specification withholds. It is also optional and sparsely logged: a forecast built on it would be a pattern invented from whichever cycles she happened to log, presented with the same confidence as one built on periods. The honest shape, if it is ever wanted, is an Insights sentence when her own history strongly supports it — same cycle phase in at least three of the last four cycles — and no calendar marks | ## What was tried and dropped @@ -59,6 +60,15 @@ This is the most useful section in the file and the one most often missing — a approach abandoned for a good reason will be proposed again by somebody who does not know it was tried, including you, in a year. +- **Predicting spotting, and the whole feature surface behind it.** Reviewing an + open-source period widget for ideas turned up a symptom library, per-cycle-day + symptom recurrence, hormone curves, water and sleep tracking, a pregnancy mode + and a phase wheel. Almost all of it is the "overloaded women's health super-app" + PRODUCT_PLAN §5 exists to refuse, and its prediction was the naive + last-start-plus-28 §11 forbids — so there was nothing to take from the maths + and little from the features. Three ideas survived as their own batch: + restore-from-export, a year-ahead calendar, and a cycle-length chart. Spotting + prediction did not, and the reason is in the decisions table above. - **A Room schema-export comparison as the schema-drift guard.** `SchemaTest` was written believing it caught drift. Room regenerates the export during compilation, so both sides of every comparison agree by construction, and