feat: Delete My Data, and fix a privacy option nobody could tap

closes #36

The deletion has existed since Batch 01 with an instrumented test and no way to
reach it. This adds the Privacy & Security section, a confirmation that says
what goes and what stays, and PrivacyViewModelTest.

A PRIVACY CONTROL WHOSE LABEL DID NOTHING

The confirmation promises "your reminder settings are unchanged". Proving that
meant changing a setting first, so I tapped "Maximum privacy" on a device and
nothing happened: PrivacyRow and onboarding's PrivacyOption both put onClick on
the RadioButton and left the row inert. The option that decides what a lock
screen shows could only be changed by hitting a 20dp circle — in both places a
user ever chooses it.

Modifier.selectable on the row, onClick = null on the radio. That is Material's
documented pattern and it also merges the semantics, so TalkBack announces one
selectable option instead of a radio button and two loose strings.

Found by trying to verify a different claim, which is the argument for verifying
claims rather than asserting them. The setting does survive deletion — set to
Maximum privacy, deleted everything, still Maximum privacy.

A DESIGN THAT WAS WRONG BEFORE IT WAS WRITTEN

The first draft cancelled the reminder schedule on delete. Seems obviously
right; is not. ReminderWorker reads the forecast each run and NoData maps to no
decision, so scheduled work already does nothing while there is nothing to say —
and scheduling only happens from ReminderCoordinator and the settings screen, so
cancelling would have left reminders silently off until the user next toggled
something, long after logging a new period. Checked the call sites instead of
reasoning from the name.

Delete touches health data only. UserPreferences is a separate store precisely
so a privacy action cannot reset a choice somebody made, and there is no undo —
§45 says irreversible, and an undo snackbar keeps the data alive for its
timeout.

Round 4 recorded in docs/qa/. 194 tests pass; ./gradlew check, schema-guard and
doc-claims all pass. Driven on PeriodMinSdk26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-18 21:03:16 -05:00
parent f43e1c2824
commit 8807951553
8 changed files with 466 additions and 21 deletions

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@ -30,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -278,13 +280,29 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard
}
@Composable
// The whole row is the target, not the radio.
//
// Found by tapping the label "Maximum privacy" on a device and watching
// nothing happen: only the RadioButton was clickable, so the option that
// decides what a lock screen shows could only be changed by hitting a
// ~20dp circle. selectable() on the Row with onClick = null on the radio is
// Material's documented pattern, and it also merges the semantics — TalkBack
// now announces one selectable option per row instead of a radio button
// followed by two loose pieces of text.
private fun PrivacyOption(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
Modifier
.fillMaxWidth()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onSelect,
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
RadioButton(selected = selected, onClick = null)
Spacer(Modifier.size(8.dp))
Column {
Text(title, style = MaterialTheme.typography.titleMedium)

View File

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
@ -27,6 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -149,13 +151,29 @@ private fun NotificationSettingsContent(
}
@Composable
// The whole row is the target, not the radio.
//
// Found by tapping the label "Maximum privacy" on a device and watching
// nothing happen: only the RadioButton was clickable, so the option that
// decides what a lock screen shows could only be changed by hitting a
// ~20dp circle. selectable() on the Row with onClick = null on the radio is
// Material's documented pattern, and it also merges the semantics — TalkBack
// now announces one selectable option per row instead of a radio button
// followed by two loose pieces of text.
private fun PrivacyRow(title: String, example: String, selected: Boolean, onSelect: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
Modifier
.fillMaxWidth()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onSelect,
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
RadioButton(selected = selected, onClick = null)
Spacer(Modifier.height(8.dp))
Column(Modifier.padding(start = 8.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)

View File

@ -0,0 +1,81 @@
package dev.privacyllc.period.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** What the Privacy & Security section is doing right now. */
enum class DeletionState { IDLE, DELETING, DONE, FAILED }
/**
* §45's Delete My Data.
*
* The deletion itself has existed since Batch 01 and had an instrumented test
* before anything could call it. This adds the part a person touches, and the
* three rules that make it a privacy control rather than a destructive button.
*
* ## It deletes health data and nothing else
*
* `UserPreferences` lives in `core/datastore`, a separate store from the cycle
* database, and that separation exists for this moment: somebody exercising a
* privacy control has not asked to have their notification privacy reset to a
* default they did not choose. A user who wipes their history and is then sent
* a Direct-mode reminder has been failed twice.
*
* ## It does not touch the reminder schedule
*
* Tempting, and wrong. `ReminderWorker` reads the forecast on each run, and
* with no history `CycleStatusRules` returns `NoData`, which `ReminderRules`
* maps to no decision so the scheduled work already does nothing while there
* is nothing to say. Cancelling it would be worse than pointless: scheduling
* only happens from `ReminderCoordinator` and the notification settings screen,
* so a cancel here would leave reminders silently off until the user next
* toggled a setting, long after they had logged a new period.
*
* ## There is no undo
*
* §45 says irreversible after confirmation, and an undo snackbar keeps the data
* alive for the length of its timeout the opposite of what was just asked
* for. The confirmation carries the whole weight, so it says plainly what goes
* and what stays.
*/
@HiltViewModel
class PrivacyViewModel @Inject constructor(
private val repository: CycleRepository,
) : ViewModel() {
private val _deletion = MutableStateFlow(DeletionState.IDLE)
val deletion: StateFlow<DeletionState> = _deletion.asStateFlow()
/**
* A failure surfaces instead of taking the process down.
*
* Batch 01's lesson, learned by tapping a button twice: a repository call
* that throws inside `viewModelScope.launch` kills the app. Here it would
* also leave the user unable to tell whether their data is gone, which is
* the worst possible moment for an ambiguous outcome.
*/
private val handler = CoroutineExceptionHandler { _, _ ->
_deletion.value = DeletionState.FAILED
}
fun deleteEverything() {
if (_deletion.value == DeletionState.DELETING) return
_deletion.value = DeletionState.DELETING
viewModelScope.launch(handler) {
repository.deleteAllHealthData()
_deletion.value = DeletionState.DONE
}
}
fun acknowledge() {
_deletion.value = DeletionState.IDLE
}
}

View File

@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -49,7 +53,13 @@ import dev.privacyllc.period.designsystem.PeriodTheme
* theme control, and Premium with Batch 07.
*/
@Composable
fun SettingsScreen(onOpenNotifications: () -> Unit) {
fun SettingsScreen(
onOpenNotifications: () -> Unit,
viewModel: PrivacyViewModel? = hiltViewModel(),
) {
val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE
var confirming by remember { mutableStateOf(false) }
Column(
Modifier
.fillMaxSize()
@ -63,6 +73,14 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) {
onClick = onOpenNotifications,
)
Spacer(Modifier.height(8.dp))
SectionHeader("Privacy & Security")
SettingsRow(
title = "Delete my data",
subtitle = "Erase every period, spotting and prediction record",
onClick = { confirming = true },
)
Spacer(Modifier.height(8.dp))
SectionHeader("About")
ExpandableRow(
@ -82,6 +100,73 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) {
)
StaticRow(title = "Version", value = versionName())
}
if (confirming) {
DeleteConfirmation(
onConfirm = {
confirming = false
viewModel?.deleteEverything()
},
onDismiss = { confirming = false },
)
}
when (deletion) {
DeletionState.DONE -> ResultDialog(
title = "Your data is deleted",
body = "Every period, spotting and prediction record has been erased " +
"from this device. Your reminder settings are unchanged.",
onDismiss = { viewModel?.acknowledge() },
)
DeletionState.FAILED -> ResultDialog(
title = "Nothing was deleted",
body = "Something went wrong and your data is still here. Try again, " +
"and if it keeps failing your records are intact.",
onDismiss = { viewModel?.acknowledge() },
)
else -> Unit
}
}
/**
* §45 wants this "clear and irreversible after confirmation", so the dialog
* does three things a default one would not.
*
* It says what is destroyed **and what is kept** a user deleting their history
* should not have to wonder whether their notification privacy went with it. It
* labels the button with the action rather than "OK", because "OK" to a question
* nobody read is how accidental deletions happen. And it does not offer an undo:
* that is the point of the control, not an omission.
*/
@Composable
private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete your data?") },
text = {
Text(
"This erases every period, spotting and prediction record on this " +
"device. Your reminder and privacy settings are kept.\n\n" +
"This cannot be undone.",
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Delete my data", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Keep my data") } },
)
}
@Composable
private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body) },
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
/**
@ -195,5 +280,5 @@ private fun StaticRow(title: String, value: String) {
)
@Composable
private fun PreviewSettingsRoot() = PeriodTheme {
SettingsScreen(onOpenNotifications = {})
SettingsScreen(onOpenNotifications = {}, viewModel = null)
}

View File

@ -0,0 +1,140 @@
package dev.privacyllc.period.feature.settings
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
/**
* §45's Delete My Data, tested for what it destroys **and what it leaves**.
*
* The second half is the one worth having. "It deleted everything" is easy to
* assert and is not the requirement: the requirement is that health history goes
* and the user's own settings do not, because `core/datastore` was made a
* separate store precisely so a privacy action could not quietly reset a choice
* somebody made.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PrivacyViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
private val today = LocalDate.of(2026, 8, 18)
private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
private lateinit var repo: CycleRepository
private lateinit var vm: PrivacyViewModel
@Before fun setUp() {
Dispatchers.setMain(dispatcher)
repo = CycleData.repository(
ApplicationProvider.getApplicationContext(),
PersonalPredictionEngine(),
clock,
)
runBlocking { repo.deleteAllHealthData() }
vm = PrivacyViewModel(repo)
}
@After fun tearDown() = Dispatchers.resetMain()
private fun await(predicate: suspend () -> Boolean) = runBlocking {
withTimeout(5_000) { while (!predicate()) delay(10) }
}
private fun seedThreeCycles() = runBlocking {
repo.confirmPeriodStart(today.minusDays(84))
repo.confirmPeriodStart(today.minusDays(56))
repo.confirmPeriodStart(today.minusDays(28))
}
@Test fun `it starts idle`() {
assertEquals(DeletionState.IDLE, vm.deletion.value)
}
@Test fun `it erases every period record`() {
seedThreeCycles()
await { repo.confirmedPeriods.first().size == 3 }
vm.deleteEverything()
await { vm.deletion.value == DeletionState.DONE }
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() })
}
@Test fun `the forecast goes with the history that produced it`() {
seedThreeCycles()
await { repo.forecast.first() != null }
vm.deleteEverything()
await { vm.deletion.value == DeletionState.DONE }
assertEquals(null, runBlocking { repo.forecast.first() })
}
/**
* The app must survive its own empty state afterwards.
*
* Deleting everything puts the user back on a screen that has never been
* reached from this direction before the first-run empty state on a
* process that has already seen data. Reading the flows is what Today does
* on recomposition, so this is the crash that would greet them.
*/
@Test fun `reading the flows after deletion does not throw`() {
seedThreeCycles()
await { repo.confirmedPeriods.first().size == 3 }
vm.deleteEverything()
await { vm.deletion.value == DeletionState.DONE }
runBlocking {
assertTrue(repo.confirmedPeriods.first().isEmpty())
assertEquals(null, repo.forecast.first())
assertTrue(repo.notYetObservations.first().isEmpty())
}
}
@Test fun `acknowledging returns it to idle so the dialog does not reappear`() {
seedThreeCycles()
vm.deleteEverything()
await { vm.deletion.value == DeletionState.DONE }
vm.acknowledge()
assertEquals(DeletionState.IDLE, vm.deletion.value)
}
/**
* Tapping twice was a real crash in Batch 01, on a different screen.
* Here the second tap must be ignored rather than starting a second
* transaction against a database the first one is emptying.
*/
@Test fun `a second delete while one is running is ignored`() {
seedThreeCycles()
vm.deleteEverything()
vm.deleteEverything()
await { vm.deletion.value == DeletionState.DONE }
assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() })
}
}

View File

@ -32,6 +32,66 @@ written and stay true. It is exempt from review for the same reason a receipt is
## Entries
### 2026-08-18 — Batch 06 starts, and two defects that only a device could show
The designed Settings screen and Delete My Data both landed. Neither is the
interesting part of the day.
**A privacy control whose label did nothing.** The delete confirmation promises
"your reminder settings are unchanged", and proving that meant changing a
setting first — so I tapped "Maximum privacy" on a device and watched nothing
happen. `PrivacyRow` and onboarding's `PrivacyOption` both put `onClick` on the
`RadioButton` and left the row inert. The option deciding what a lock screen
shows could only be changed by hitting a 20dp circle, in the two places a user
ever chooses it. Fixed with `Modifier.selectable` on the row, which also merges
the semantics so TalkBack announces one option instead of a radio and two loose
strings.
It was found by trying to verify a different claim. That is worth noting on its
own: the check that found it was not looking for it.
**And the setting does survive deletion** — set to Maximum privacy, deleted
everything, still Maximum privacy. Now demonstrated rather than argued from the
fact that `core/datastore` is a separate store.
**A design I had wrong before writing it.** My first draft of `PrivacyViewModel`
cancelled the reminder schedule on delete, which seems obviously right and is
not. `ReminderWorker` reads the forecast each run and `NoData` maps to no
decision, so the scheduled work already does nothing while there is nothing to
say — and scheduling only happens from `ReminderCoordinator` and the settings
screen, so cancelling would have left reminders silently off until the user next
toggled something, long after they had logged a new period. Checked the call
sites instead of reasoning from the name.
**Also fixed, from the font-scale pass:** the bottom navigation wrapped its
labels mid-word at scale 2.0 — *"Calenda / r"*. `maxLines = 1` and an ellipsis.
That defect predates all of today's work; the font-scale gap is simply the first
thing that looked.
**Settings shows only the sections that have something behind them.** A
greyed-out "Delete My Data" tells a user that control over their own health data
exists and that they may not have it, which is a bad first thing for this
product to say. Recorded in `docs/design/README.md` so the next person adding a
row knows it was a decision.
Round 4 recorded in `docs/qa/`. Four rounds in, the pattern has not moved: every
defect of consequence has been found by a person using the app, and none by a
test.
- **Closed:** #29, #33, #36.
- **Next action:** Continue Batch 06. #38, the release-build logging guard, is
independent of the rest and can go next — write the guard first and prove it
fails, per `docs/architecture/GUARDS.md`, because three guards in this project
have been green over the exact failure they claimed to catch. #35 export and
#37 the privacy promise both need only the Settings screen, which now exists.
#34, app lock, still carries an open product question: what happens when
somebody forgets their PIN, given an irreversible delete sits on the same
screen.
- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack
has still never been run and no real lock screen has been looked at — and
after today's find, the TalkBack gap looks more expensive than it did: two
screens had merged-semantics problems nobody would see without it.
### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong
All eight illustrations are in the app in both themes, every onboarding step has

View File

@ -140,6 +140,42 @@ Invisible to the unit tests and invisible to an API 36 emulator. Fixed, and
worth recording as a standing gap below: **this project has no test on a device
at its own `minSdk`.**
## Round 4 — 2026-08-18 at `f43e1c2`, partial
Batch 06 began: the designed Settings screen and Delete My Data. Driven by hand
on `PeriodMinSdk26`, plus the font-scale pass listed under the gaps below.
### What driving it found that no test would have
Two defects, both in controls that already looked finished:
1. **Only the radio button in a privacy option was clickable.** Tapping the
label "Maximum privacy" did nothing — `PrivacyRow` and onboarding's
`PrivacyOption` put `onClick` on the `RadioButton` and left the row inert, so
the control that decides what a lock screen shows could only be changed by
hitting a ~20dp circle. Found while trying to *prove something else*: the
delete confirmation promises "your reminder settings are unchanged", and
testing that claim meant changing a setting first, which would not work.
Fixed with `Modifier.selectable` on the row and `onClick = null` on the
radio, which also merges the semantics for TalkBack.
2. **The navigation bar wrapped its labels mid-word at font scale 2.0**
recorded under the font-scaling gap below.
Neither is visible in a unit test, a preview, or a code review. Both took one
person tapping the thing.
### What was verified rather than assumed
- Delete My Data erases every period, spotting and prediction record, and the
user's notification-privacy choice **survives it** — set to Maximum privacy,
deleted, still Maximum privacy afterwards. That is the claim the confirmation
dialog makes to the user, so it is checked on a device rather than argued from
the code.
- Today renders its empty state after a deletion without crashing, which is the
first-run screen reached from a direction it had never been reached from.
- `PrivacyViewModelTest` covers the same ground on the JVM, including a second
delete while one is running.
## Standing gaps
Things no round has ever covered, carried forward until they are. This list

View File

@ -19,21 +19,22 @@ Review trigger: Any QA round run
## Current run-state
- **Last QA round:** Round 3 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial)
- **Last verified build SHA:** `0451fbe`
- **Last tested device / environment:** emulator `PeriodQA`, API 36, Pixel 6 profile, debug build
- **Overall status:** Five of eight batches are done and the app is a coherent
product: onboarding to a forecast, a Today screen with six honest states,
two-tap logging, a calendar readable in greyscale, insights that decline to
overstate, fertility estimates that refuse to appear when the forecast is too
vague to support them, and discreet reminders. It now wears its real brand,
supplied by the owner. Four instrumented tests assert that nothing about a
period reaches a lock screen in either private mode — but **nobody has yet
looked at an actual locked screen**, and that last mile is the single most
valuable thing left to check, because a notification read over a shoulder is
the likeliest real privacy failure in this product. The other standing gaps
are of the same kind: TalkBack has never been run, text has never been scaled,
and nothing has run at the minimum Android version this app claims to support.
- **Last QA round:** Round 4 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial)
- **Last verified build SHA:** `f43e1c2`
- **Last tested device / environment:** emulator `PeriodMinSdk26`, API 26, Pixel 6 profile, debug build, at font scales 1.0, 1.3 and 2.0
- **Overall status:** Five of eight batches are done and Batch 06 has begun:
Settings is now the screen §36 describes, and Delete My Data is reachable,
confirmed and irreversible. The app has run at its minimum Android version and
at the largest accessibility font scale, and both found defects that had been
shipping unnoticed — a navigation bar that wrapped its labels mid-word, and a
privacy option whose label was not clickable at all, so the control deciding
what a lock screen shows could only be changed by hitting the radio button
exactly. Both are fixed. The pattern is now four rounds old and unchanged:
**every defect of consequence in this project has been found by a person using
the app, and none by a test.** The tests are what stop them coming back. The
standing gaps are the same shape — nobody has yet looked at an actual locked
screen, and TalkBack has still never been run, which is now the single most
valuable unspent hour.
## Open defects
@ -61,6 +62,12 @@ One entry per round, the verdict only. What each pass reached, and what it could
not, lives in [`ClaudeQACoverage.md`](ClaudeQACoverage.md) and is deliberately
not repeated here.
- **Round 4 — 2026-08-18 at `f43e1c2`, partial.** Batch 06 began, and driving
it found two defects in controls that already looked finished: navigation
labels wrapping mid-word at font scale 2.0, and a privacy option whose row was
inert because only its radio button was clickable. Verdict: the privacy
controls now behave as their own dialog promises — checked by setting a
preference, deleting all data, and confirming the preference survived.
- **Round 3 — 2026-08-18 at `0451fbe`, partial.** Fertility and reminders landed
and pass F became runnable for the first time. Verdict: fit to keep building,
not fit to ship — the privacy promise is proved against the notification