feat: pin down which day, and let the reminder be any time

Two specification gaps that were filed as deferred and turned out to be
mostly affordable now.

§30 asks the follow-up reminder to pin down the day: Yes Today, Yes
Yesterday, Choose Date, Not Yet. It offers three of those. Android
reliably renders three notification actions, and a fourth that appears on
some devices and not others is worse than one that never appears -- a
feature the user learns and then loses. The one left out is Choose Date,
which was never really a button: it needs a screen, and logging an older
start in the app is the route to that today.

"Yes, yesterday" is cheap only because an answer now carries the day it
was about. It means the day before the QUESTION, never the day before the
tap, or it drifts exactly like the defect dated actions fixed.

§29 lists a custom reminder time beside the three presets. The preference
has always stored an arbitrary minute; only the screen insisted on one of
three, and "around dinner" is not the same hour in every house. The
keyboard-entry toggle stays on: a dial is quick with a thumb and miserable
with a screen reader, and this is a control somebody may set once.

One test had pinned the follow-up's exact button list, so adding a third
broke it. It asserts the property now -- a question about a start never
offers an ending -- which is what it was there to protect and does not
have to be edited every time the copy grows.

closes #75

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-21 01:09:33 -05:00
parent 5f6412856e
commit f99197ffd5
8 changed files with 157 additions and 6 deletions

View File

@ -1,6 +1,14 @@
package dev.privacyllc.period.feature.settings
import android.Manifest
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.material3.TimePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import android.text.format.DateFormat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.Lifecycle
@ -114,6 +122,16 @@ private fun NotificationSettingsContent(
blocked: Boolean = false,
onOpenSystemSettings: () -> Unit = {},
) {
var showTimePicker by rememberSaveable { mutableStateOf(false) }
if (showTimePicker) {
ReminderTimePicker(
initial = prefs.reminderTime,
onDismiss = { showTimePicker = false },
onPicked = { viewModel?.setReminderTime(it); showTimePicker = false },
)
}
Column(
Modifier
.fillMaxSize()
@ -186,6 +204,10 @@ private fun NotificationSettingsContent(
).forEach { (label, time) ->
TextButton(onClick = { viewModel?.setReminderTime(time) }) { Text(label) }
}
// §29 lists a custom time beside the three presets. The preference
// has always stored an arbitrary minute; only the screen insisted on
// one of three. "Around dinner" is not the same hour in every house.
TextButton(onClick = { showTimePicker = true }) { Text("Custom") }
}
Text(
"Currently ${prefs.reminderTime.format(timeFormat)}",
@ -283,3 +305,36 @@ private fun openAppNotificationSettings(context: Context) {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
/**
* Pick any minute of the day.
*
* A dialog rather than an inline picker: this is one decision, made rarely, and
* the settings screen is a list rather than a form. The keyboard-entry toggle
* that `TimePicker` offers is left on a dial is quick with a thumb and
* miserable with a screen reader, and this is one of the few controls somebody
* might set once and never touch again.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReminderTimePicker(
initial: LocalTime,
onDismiss: () -> Unit,
onPicked: (LocalTime) -> Unit,
) {
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
is24Hour = DateFormat.is24HourFormat(LocalContext.current),
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Remind me at") },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = { onPicked(LocalTime.of(state.hour, state.minute)) }) { Text("Set") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

View File

@ -77,6 +77,15 @@ class NotificationActionHandler @Inject constructor(
PeriodRecordSource.NOTIFICATION_CONFIRMATION,
)
// The day before the one asked about — not the day before
// the tap, which would drift exactly like the bug the dated
// actions fixed.
ReminderAction.STARTED_YESTERDAY ->
repository.confirmPeriodStart(
request.date!!.minusDays(1),
PeriodRecordSource.NOTIFICATION_CONFIRMATION,
)
// Exactly what the Today screen's "Not yet" does — same
// call, same censoring observation, same re-conditioned
// forecast — but dated to the day that was asked about.

View File

@ -108,9 +108,17 @@ class NotificationActionHandlerTest {
val end = NotificationCopy.buttons(ReminderKind.PERIOD_END_CHECK_IN, privacy)
assertEquals(listOf(ReminderAction.ENDED, ReminderAction.STILL_GOING), end.map { it.action })
// The property, not the exact list: the follow-up gained a third
// button (§30's "Yes, yesterday") and the invariant is unchanged —
// a question about a start never offers an end.
listOf(ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY).forEach { kind ->
val start = NotificationCopy.buttons(kind, privacy)
assertEquals(listOf(ReminderAction.STARTED, ReminderAction.NOT_YET), start.map { it.action })
val start = NotificationCopy.buttons(kind, privacy).map { it.action }
assertTrue("$kind offered no way to say it started", ReminderAction.STARTED in start)
assertTrue("$kind offered no way to say not yet", ReminderAction.NOT_YET in start)
assertTrue(
"$kind offered an ending for a question about a start",
start.none { it == ReminderAction.ENDED || it == ReminderAction.STILL_GOING },
)
}
(end + NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)).forEach {
@ -244,4 +252,16 @@ class NotificationActionHandlerTest {
assertTrue(repo.confirmedPeriods.first().isEmpty())
}
@Test
fun `yes-yesterday records the day before the one asked about`() = runTest {
// Not the day before the tap. The reminder asked about yesterday and is
// answered this morning: "yesterday" means the day before the question,
// or it drifts exactly like the bug dated actions fixed.
assertTrue(
handler.handle(ReminderActionRequest(ReminderAction.STARTED_YESTERDAY, today.minusDays(1))),
)
assertEquals(today.minusDays(2), repo.confirmedPeriods.first().single().startDate)
}
}

View File

@ -157,12 +157,25 @@ object NotificationCopy {
fun buttons(kind: ReminderKind, privacy: NotificationPrivacy): List<ReminderButton> {
val direct = privacy == NotificationPrivacy.DIRECT
return when (kind) {
// "Has it started?" — a start, or a censoring observation.
ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY -> listOf(
// The day it was expected: it either has or it has not.
ReminderKind.PERIOD_EXPECTED_TODAY -> listOf(
ReminderButton(ReminderAction.STARTED, if (direct) "Started" else "Yes"),
ReminderButton(ReminderAction.NOT_YET, "Not yet"),
)
// The follow-up, a couple of days later, where "yes" is ambiguous —
// §30 asks for the day to be pinned down. Three buttons, not §30's
// four: Android reliably renders three, and a fourth that only
// appears on some devices is worse than one that never does. The
// missing one is "Choose Date", which needs a screen rather than a
// button; logging an older start in the app is the way to that
// today.
ReminderKind.DID_IT_START -> listOf(
ReminderButton(ReminderAction.STARTED, if (direct) "Today" else "Yes, today"),
ReminderButton(ReminderAction.STARTED_YESTERDAY, if (direct) "Yesterday" else "Yes, yesterday"),
ReminderButton(ReminderAction.NOT_YET, "Not yet"),
)
// "Is it over?" — an end, or nothing at all. Never a start.
ReminderKind.PERIOD_END_CHECK_IN -> listOf(
ReminderButton(ReminderAction.ENDED, if (direct) "Ended" else "Done"),

View File

@ -25,9 +25,19 @@ package dev.privacyllc.period.core.notifications
* nothing" rather than guessing.
*/
enum class ReminderAction {
/** The period began. Confirms a start. */
/** The period began on the day the reminder was asking about. */
STARTED,
/**
* The period began the day *before* the one the reminder asked about.
*
* §30's "Yes, Yesterday". Cheap only because the answer already carries the
* day it was about: this is that day minus one, not "yesterday" measured from
* whenever the tap happened, which would drift exactly like the bug dated
* actions fixed.
*/
STARTED_YESTERDAY,
/** It has not begun yet. Censors the forecast — §13. */
NOT_YET,

View File

@ -53,7 +53,7 @@ object ReminderActionRules {
// Already answered. She logged a start on or after the day the
// notification was asking about, so the question is settled and a
// second write would either duplicate it or contradict it.
ReminderAction.STARTED, ReminderAction.NOT_YET ->
ReminderAction.STARTED, ReminderAction.STARTED_YESTERDAY, ReminderAction.NOT_YET ->
if (latest != null && !latest.startDate.isBefore(date)) Verdict.Stale else Verdict.Apply
// Ending needs something open that began on or before the day in

View File

@ -190,4 +190,33 @@ class NotificationCopyTest {
assertTrue(NotificationCopy.stopAskingText(it).publicTitle.isNotBlank())
}
}
// -----------------------------------------------------------------------
// §30's follow-up, within the platform's limit
// -----------------------------------------------------------------------
@Test fun `the follow-up pins down which day, without a fourth button`() {
NotificationPrivacy.entries.forEach { privacy ->
val buttons = NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)
assertEquals(
listOf(ReminderAction.STARTED, ReminderAction.STARTED_YESTERDAY, ReminderAction.NOT_YET),
buttons.map { it.action },
)
// Three, not §30's four: Android reliably renders three, and a
// button that appears on some devices is worse than one that never
// does.
assertTrue("Android shows three actions", buttons.size <= 3)
}
}
@Test fun `the follow-up's day labels say nothing outside Direct`() {
NotificationPrivacy.entries.filter { it != NotificationPrivacy.DIRECT }.forEach { privacy ->
val labels = NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)
.joinToString(" ") { it.label }.lowercase()
NotificationCopy.SENSITIVE_WORDS.forEach {
assertFalse("$privacy leaked \"$it\" in a button", labels.contains(it))
}
}
}
}

View File

@ -153,6 +153,21 @@ have replaced it.
## The lock screen is the one semi-public surface
### Three buttons, because Android shows three
§30 asks the follow-up reminder for four: *Yes, Today*, *Yes, Yesterday*,
*Choose Date*, *Not Yet*. Android reliably renders three notification actions,
and a button that appears on some devices and not others is worse than one that
never appears at all — it is a feature the user learns and then loses.
So the follow-up offers the three that fit, and the one left out is *Choose
Date*, which was never really a button: it needs a screen. Logging an older start
in the app is the route to that today, and the notification opens the app.
"Yes, yesterday" is cheap only because the answer already carries the day it was
about. It means the day before the *question*, never the day before the tap —
otherwise it drifts exactly like the defect dated actions fixed.
### When the copy and the platform disagree, the copy moves
The reminders screen promised delivery "a few minutes either side of this time".