feat: period CRUD end to end, and stop a double tap killing the app

The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.

THE DEFECT THIS FOUND, ON A DEVICE

Tapping "Started today" twice on the same day killed the app:

  FATAL EXCEPTION: main
  android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
  period_records.startDate

Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.

The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.

editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.

The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).

Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.

70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.

closes #6
This commit is contained in:
null 2026-08-18 02:52:35 -05:00
parent 6d4592467f
commit adc50751d8
13 changed files with 860 additions and 27 deletions

View File

@ -52,12 +52,15 @@ android {
dependencies { dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data"))
implementation(project(":core:datastore"))
implementation(project(":domain:cycle")) implementation(project(":domain:cycle"))
implementation(project(":domain:prediction")) implementation(project(":domain:prediction"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)

View File

@ -0,0 +1,72 @@
package dev.privacyllc.period.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionEngine
import java.time.Clock
import javax.inject.Singleton
/**
* Where the app learns what a file path is, and the only place it does.
*
* Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in
* this module or anywhere above it. `CycleData.repository` hands back a
* repository and keeps the storage to itself see
* docs/architecture/README.md.
*/
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
/**
* Singleton because Room and DataStore both are: two instances over one
* file is a corruption bug that only shows up under concurrency.
*/
@Provides
@Singleton
fun cycleRepository(
@ApplicationContext context: Context,
engine: PredictionEngine,
clock: Clock,
): CycleRepository = CycleData.repository(context, engine, clock)
@Provides
@Singleton
fun preferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("user_preferences")
}
@Provides
@Singleton
fun userPreferencesRepository(store: DataStore<Preferences>) = UserPreferencesRepository(store)
/**
* Batch 02 replaces this binding, and only this binding.
*
* The rest of the app depends on [PredictionEngine], never on an
* implementation so swapping in the real engine is one line here, and the
* §51 acceptance tests can run against both to show the replacement is
* better rather than merely different.
*/
@Provides
@Singleton
fun predictionEngine(): PredictionEngine = BaselinePredictionEngine()
/** Injected rather than read from the environment, so §50's date edge cases stay testable. */
@Provides
@Singleton
fun clock(): Clock = Clock.systemDefaultZone()
}

View File

@ -0,0 +1,235 @@
package dev.privacyllc.period.feature.today
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* A working surface, and deliberately not the designed one.
*
* PRODUCT_PLAN.md §21§22 specify the Today screen: a hero number, six dynamic
* states, restrained motion. That is Batch 03. This exists to prove the loop in
* §58 end to end enter starts, store them, recalculate, see it change and
* it says "Batch 01 · working surface" at the top so nobody mistakes it for the
* real thing. A convincing mock is how a screen comes to be believed finished.
*/
@Composable
fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
"Batch 01 · working surface — the designed Today screen is Batch 03",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { ForecastCard(state, viewModel) }
message?.let { text ->
item {
Card(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = viewModel::messageShown) { Text("OK") }
}
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.confirmStart(state.today) }) { Text("Started today") }
OutlinedButton(onClick = { viewModel.notYet() }) { Text("Not yet") }
}
}
item {
Text("History", style = MaterialTheme.typography.titleMedium)
if (state.periods.isEmpty() && !state.loading) {
Text(
"No periods logged yet. Log one above, or add an earlier one to " +
"help the app learn your cycle faster.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(state.periods, key = { it.id }) { record ->
PeriodRow(
record = record,
onShiftStart = { days -> viewModel.edit(record.id, record.startDate.plusDays(days), record.endDate) },
onEndToday = { viewModel.setEnd(record.id, state.today) },
onStillGoing = { viewModel.setEnd(record.id, null) },
onDelete = { viewModel.delete(record.id) },
)
}
if (state.cycles.isNotEmpty()) {
item {
HorizontalDivider()
Text("Cycle lengths", style = MaterialTheme.typography.titleMedium)
Text(
state.cycles.joinToString(" · ") { "${it.cycleLengthDays}d" },
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (state.accuracy.hasEnoughToShow) {
item { AccuracyCard(state) }
}
}
}
@Composable
private fun ForecastCard(state: TodayUiState, viewModel: TodayViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
val forecast = state.forecast
if (forecast == null) {
Text("No forecast yet", style = MaterialTheme.typography.titleLarge)
Text(
"Log a period and the app will start learning your cycle.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
state.cycleDay?.let { Text("Cycle day $it", style = MaterialTheme.typography.labelLarge) }
val days = state.daysUntil
// The window and the confidence travel with the date, always.
// §8 and §15: never a bare exact date presented as fact.
Text(
when {
days == null -> "Period expected"
days > 1L -> "Period likely in $days days"
days == 1L -> "Period likely tomorrow"
days == 0L -> "Your period may start today"
else -> "Not yet?"
},
style = MaterialTheme.typography.titleLarge,
)
Text("Most likely ${forecast.mostLikelyStartDate.pretty()}")
Text("Expected ${forecast.windowStart.pretty()} ${forecast.windowEnd.pretty()}")
Text(
"Confidence ${forecast.confidenceLabel.label()}",
modifier = Modifier.clearAndSetSemantics {
contentDescription = "Prediction confidence: ${forecast.confidenceLabel.label()}"
},
)
}
}
}
@Composable
private fun AccuracyCard(state: TodayUiState) {
val a = state.accuracy
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Prediction accuracy", style = MaterialTheme.typography.titleMedium)
a.lastSignedErrorDays?.let {
Text(
when {
it == 0 -> "Last prediction: exact"
it < 0 -> "Last prediction: ${-it} day(s) early"
else -> "Last prediction: $it day(s) late"
},
)
}
a.meanAbsoluteErrorDays?.let { Text("Average error: ${"%.1f".format(it)} days") }
Text("${a.withinOneDay} of your last ${a.scoredCount} were within one day")
}
}
}
@Composable
private fun PeriodRow(
record: PeriodRecord,
onShiftStart: (Long) -> Unit,
onEndToday: () -> Unit,
onStillGoing: () -> Unit,
onDelete: () -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(record.startDate.pretty(), style = MaterialTheme.typography.titleSmall)
Text(
record.endDate?.let { "ended ${it.pretty()}" } ?: "still going",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
record.source.name.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onShiftStart(-1) }) { Text("1d") }
TextButton(onClick = { onShiftStart(1) }) { Text("+1d") }
if (record.endDate == null) {
TextButton(onClick = onEndToday) { Text("Ended") }
} else {
TextButton(onClick = onStillGoing) { Text("Still going") }
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
}
/** "Low", not "LOW" — the user-facing labels in §15 are words, not enum names. */
private fun ConfidenceLabel.label() = when (this) {
ConfidenceLabel.LOW -> "Low"
ConfidenceLabel.MEDIUM -> "Medium"
ConfidenceLabel.HIGH -> "High"
}
private val formatter = DateTimeFormatter.ofPattern("d MMM yyyy")
private fun LocalDate.pretty(): String = format(formatter)

View File

@ -0,0 +1,129 @@
package dev.privacyllc.period.feature.today
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.data.PeriodWriteResult
import dev.privacyllc.period.core.data.PredictionAccuracy
import dev.privacyllc.period.domain.cycle.CycleRecord
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.prediction.Prediction
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.LocalDate
import javax.inject.Inject
/**
* Screen state, as one immutable object.
*
* [daysUntil] is derived here rather than in the composable so the arithmetic
* has a place it can be tested. It is null when there is no forecast, which is
* a different thing from zero.
*/
data class TodayUiState(
val loading: Boolean = true,
val periods: List<PeriodRecord> = emptyList(),
val cycles: List<CycleRecord> = emptyList(),
val forecast: Prediction? = null,
val accuracy: PredictionAccuracy = PredictionAccuracy.Empty,
val today: LocalDate = LocalDate.EPOCH,
) {
val daysUntil: Long?
get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() }
val cycleDay: Long?
get() = periods.maxByOrNull { it.startDate }
?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 }
?.takeIf { it > 0 }
}
@HiltViewModel
class TodayViewModel @Inject constructor(
private val repository: CycleRepository,
private val clock: Clock,
) : ViewModel() {
val state: StateFlow<TodayUiState> =
combine(
repository.confirmedPeriods,
repository.cycles,
repository.forecast,
repository.accuracy,
) { periods, cycles, forecast, accuracy ->
TodayUiState(
loading = false,
periods = periods.sortedByDescending { it.startDate },
cycles = cycles,
forecast = forecast,
accuracy = accuracy,
today = LocalDate.now(clock),
)
}.stateIn(
scope = viewModelScope,
// Keeps the database subscription alive briefly across a rotation
// rather than tearing it down and re-reading, which is what makes
// the forecast look like it flickers.
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TodayUiState(today = LocalDate.now(clock)),
)
private val _message = MutableStateFlow<String?>(null)
/** A transient line for the user. Null when there is nothing to say. */
val message: StateFlow<String?> = _message.asStateFlow()
fun messageShown() { _message.value = null }
/**
* Nothing the user can tap may take the app down.
*
* `viewModelScope.launch` has no handler by default, so any exception from a
* repository write reaches the default handler and kills the process. That
* is how tapping **Started today** twice crashed the app on a device: the
* UNIQUE constraint on `startDate` threw straight through.
*
* The ordinary outcomes are now values rather than exceptions
* ([PeriodWriteResult]), which is the real fix. This is the backstop for
* everything else a full disk, a corrupt database because in a 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.
*/
private val handler = CoroutineExceptionHandler { _, e ->
// No cycle date, no record contents — §45. The exception's type is
// enough to act on and carries nothing sensitive.
_message.value = "Could not save that (${e::class.simpleName}). Nothing was changed."
}
private fun write(block: suspend () -> Unit) = viewModelScope.launch(handler) { block() }
fun confirmStart(date: LocalDate) = write {
when (val r = repository.confirmPeriodStart(date)) {
is PeriodWriteResult.AlreadyRecorded -> _message.value = "That day is already logged."
is PeriodWriteResult.Added -> _message.value = null
else -> Unit.also { check(r !is PeriodWriteResult.Conflict) }
}
}
fun edit(id: Long, start: LocalDate, end: LocalDate?) = write {
when (repository.editPeriod(id, start, end)) {
is PeriodWriteResult.Conflict ->
_message.value = "There is already a period logged on that date."
PeriodWriteResult.NotFound -> _message.value = "That record is no longer there."
else -> _message.value = null
}
}
fun setEnd(id: Long, end: LocalDate?) = write { repository.setPeriodEnd(id, end) }
fun delete(id: Long) = write { repository.deletePeriod(id) }
fun notYet() = write { repository.recordNotYet(LocalDate.now(clock)) }
}

View File

@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.privacyllc.period.R import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.today.TodayScreen
/** /**
* The four tabs from docs/planning/PRODUCT_PLAN.md §20. * The four tabs from docs/planning/PRODUCT_PLAN.md §20.
@ -83,11 +84,17 @@ fun PeriodApp() {
startDestination = PeriodDestination.TODAY.route, startDestination = PeriodDestination.TODAY.route,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
) { ) {
PeriodDestination.entries.forEach { destination -> composable(PeriodDestination.TODAY.route) { TodayScreen() }
composable(destination.route) {
PlaceholderScreen(stringResource(destination.labelRes)) // Calendar, Insights and Settings arrive in Batches 03 and 06. They
// say "not built yet" rather than showing a convincing mock.
PeriodDestination.entries
.filter { it != PeriodDestination.TODAY }
.forEach { destination ->
composable(destination.route) {
PlaceholderScreen(stringResource(destination.labelRes))
}
} }
}
} }
} }
} }
@ -125,6 +132,6 @@ private fun PlaceholderScreen(title: String) {
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun PeriodAppPreview() { private fun PlaceholderPreview() {
PeriodTheme { PeriodApp() } PeriodTheme { PlaceholderScreen("Calendar") }
} }

View File

@ -0,0 +1,103 @@
package dev.privacyllc.period.feature.today
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
import dev.privacyllc.period.domain.prediction.Prediction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.LocalDate
/**
* The two pieces of arithmetic the Today screen shows as its largest text.
*
* Derived on [TodayUiState] rather than inside a composable precisely so they
* can be tested here a countdown that is off by one is the kind of defect a
* user notices immediately and a screenshot review never does.
*/
class TodayUiStateTest {
private val today = LocalDate.of(2026, 8, 18)
private fun forecastOn(date: LocalDate) = Prediction(
mostLikelyStartDate = date,
windowStart = date.minusDays(1),
windowEnd = date.plusDays(1),
confidenceScore = 0.7,
confidenceLabel = ConfidenceLabel.HIGH,
modelVersion = "test",
)
private fun state(
forecast: Prediction? = null,
periodStarts: List<LocalDate> = emptyList(),
) = TodayUiState(
loading = false,
periods = periodStarts.mapIndexed { i, d -> PeriodRecord(id = i.toLong(), startDate = d) },
forecast = forecast,
today = today,
)
@Test fun `no forecast means no countdown rather than zero`() {
// Zero would render as "your period may start today", which is a claim.
assertNull(state().daysUntil)
}
@Test fun `the countdown is whole days from today`() {
assertEquals(4L, state(forecastOn(LocalDate.of(2026, 8, 22))).daysUntil)
assertEquals(1L, state(forecastOn(LocalDate.of(2026, 8, 19))).daysUntil)
assertEquals(0L, state(forecastOn(today)).daysUntil)
}
@Test fun `a forecast already passed counts negative rather than clamping to zero`() {
// The screen says "Not yet?" here. Clamping to 0 would keep saying "may
// start today" for days, which §22 explicitly calls out as the wrong
// tone — and would hide that the forecast needs re-conditioning.
assertEquals(-3L, state(forecastOn(LocalDate.of(2026, 8, 15))).daysUntil)
}
@Test fun `cycle day counts from the most recent start, inclusive`() {
// The day a period starts is cycle day 1, not day 0.
assertEquals(1L, state(periodStarts = listOf(today)).cycleDay)
assertEquals(18L, state(periodStarts = listOf(LocalDate.of(2026, 8, 1))).cycleDay)
}
@Test fun `cycle day uses the latest start regardless of list order`() {
assertEquals(
18L,
state(
periodStarts = listOf(
LocalDate.of(2026, 6, 1),
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 7, 1),
),
).cycleDay,
)
}
@Test fun `no history means no cycle day`() {
assertNull(state().cycleDay)
}
@Test fun `a start date in the future does not produce a zero or negative cycle day`() {
// Reachable by editing a record forward. "Cycle day 0" or "-2" is
// nonsense on screen; showing nothing is honest.
assertNull(state(periodStarts = listOf(today.plusDays(2))).cycleDay)
}
@Test fun `a countdown spans month and year boundaries correctly`() {
val newYear = TodayUiState(
loading = false,
forecast = forecastOn(LocalDate.of(2027, 1, 2)),
today = LocalDate.of(2026, 12, 28),
)
assertEquals(5L, newYear.daysUntil)
val leap = TodayUiState(
loading = false,
forecast = forecastOn(LocalDate.of(2028, 3, 1)),
today = LocalDate.of(2028, 2, 27),
)
assertEquals(3L, leap.daysUntil) // 2028 is a leap year: 27th, 28th, 29th, 1st
}
}

View File

@ -8,6 +8,7 @@ android {
defaultConfig { defaultConfig {
minSdk = 26 minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
compileOptions { compileOptions {
@ -39,4 +40,10 @@ dependencies {
testImplementation(libs.androidx.room.runtime) testImplementation(libs.androidx.room.runtime)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.core)
androidTestImplementation(project(":core:database"))
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.room.runtime)
androidTestImplementation(libs.kotlinx.coroutines.test)
} }

View File

@ -0,0 +1,132 @@
package dev.privacyllc.period.core.data
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
/**
* The acceptance check for the Batch 01 vertical slice, on a real device,
* against a real file-backed database.
*
* The unit tests use an in-memory database, which proves the logic and proves
* nothing about persistence: an in-memory database cannot fail to survive a
* restart because it was never expected to. This one closes the database and
* reopens it from disk, which is what a force-stop actually does to the app.
*/
@RunWith(AndroidJUnit4::class)
class PeriodCrudTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val dbName = "crud-test.db"
private val clock = Clock.fixed(
LocalDate.of(2026, 8, 18).atStartOfDay(ZoneOffset.UTC).toInstant(),
ZoneOffset.UTC,
)
private lateinit var db: PeriodDatabase
private lateinit var repo: CycleRepository
private fun open() {
db = Room.databaseBuilder(context, PeriodDatabase::class.java, dbName).build()
repo = CycleRepository(db, BaselinePredictionEngine(), clock)
}
@Before fun setUp() {
context.deleteDatabase(dbName)
open()
}
@After fun tearDown() {
db.close()
context.deleteDatabase(dbName)
}
@Test
fun addEditDeleteAndSurviveRestart() = runTest {
// --- add three starts -------------------------------------------------
repo.confirmPeriodStart(LocalDate.of(2026, 6, 1))
repo.confirmPeriodStart(LocalDate.of(2026, 6, 30))
repo.confirmPeriodStart(LocalDate.of(2026, 7, 29))
// Regression: a repeat of a date already logged must be a value, not a
// crash. This is the interaction that killed the app on a device.
assertTrue(
repo.confirmPeriodStart(LocalDate.of(2026, 7, 29)) is PeriodWriteResult.AlreadyRecorded,
)
assertEquals(3, repo.confirmedPeriods.first().size)
assertEquals(listOf(29, 29), repo.cycles.first().map { it.cycleLengthDays })
val afterAdd = repo.forecast.first()
assertNotNull("three confirmed starts must produce a forecast", afterAdd)
assertEquals(LocalDate.of(2026, 8, 27), afterAdd!!.mostLikelyStartDate)
// --- editing changes the forecast ------------------------------------
val latest = repo.confirmedPeriods.first().last()
repo.editPeriod(latest.id, LocalDate.of(2026, 8, 2), null)
val afterEdit = repo.forecast.first()!!
assertNotEquals(
"editing a record must move the forecast, not leave a stale one",
afterAdd.mostLikelyStartDate,
afterEdit.mostLikelyStartDate,
)
assertEquals(listOf(29, 33), repo.cycles.first().map { it.cycleLengthDays })
// --- deleting changes it again ---------------------------------------
val middle = repo.confirmedPeriods.first()[1]
repo.deletePeriod(middle.id)
assertEquals(2, repo.confirmedPeriods.first().size)
val afterDelete = repo.forecast.first()!!
assertNotEquals(afterEdit.mostLikelyStartDate, afterDelete.mostLikelyStartDate)
// --- close and reopen: what a force-stop does -------------------------
db.close()
open()
val reopened = repo.confirmedPeriods.first()
assertEquals("records must survive the process dying", 2, reopened.size)
assertEquals(
listOf(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 8, 2)),
reopened.map { it.startDate },
)
assertEquals(
"the forecast must be identical after a restart — it is derived, not cached",
afterDelete.mostLikelyStartDate,
repo.forecast.first()!!.mostLikelyStartDate,
)
}
@Test
fun deletingAllHealthDataSurvivesRestart() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 6, 1))
repo.confirmPeriodStart(LocalDate.of(2026, 6, 30))
repo.recordSpotting(LocalDate.of(2026, 6, 14))
repo.deleteAllHealthData()
db.close()
open()
// Delete My Data is irreversible after confirmation — §45. If anything
// came back after a restart, it was never deleted, only hidden.
assertEquals(0, repo.confirmedPeriods.first().size)
assertEquals(0, repo.spotting.first().size)
assertEquals(null, repo.forecast.first())
}
}

View File

@ -124,7 +124,15 @@ class CycleRepository internal constructor(
suspend fun confirmPeriodStart( suspend fun confirmPeriodStart(
startDate: LocalDate, startDate: LocalDate,
source: PeriodRecordSource = PeriodRecordSource.MANUAL, source: PeriodRecordSource = PeriodRecordSource.MANUAL,
): Long = db.inTransaction { ): PeriodWriteResult = db.inTransaction {
// Checked inside the transaction, before inserting. Tapping the primary
// button twice is an ordinary thing for a person to do when they are not
// sure the first tap registered — it must not be an error, and it
// certainly must not be a crash. See PeriodWriteResult.
periodDao.byStartDate(startDate)?.let {
return@inTransaction PeriodWriteResult.AlreadyRecorded(it.id)
}
val now = clock.instant() val now = clock.instant()
val id = periodDao.insert( val id = periodDao.insert(
PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now), PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now),
@ -133,16 +141,17 @@ class CycleRepository internal constructor(
scoreOutstanding(startDate) scoreOutstanding(startDate)
notYetDao.deleteBefore(startDate.plusDays(1)) notYetDao.deleteBefore(startDate.plusDays(1))
snapshotForecast(basedOnPeriodId = id) snapshotForecast(basedOnPeriodId = id)
id PeriodWriteResult.Added(id)
} }
/** "Still going" answered, or a date chosen. Never invents an end date. */ /** "Still going" answered, or a date chosen. Never invents an end date. */
suspend fun setPeriodEnd(id: Long, endDate: LocalDate?) = db.inTransaction { suspend fun setPeriodEnd(id: Long, endDate: LocalDate?): PeriodWriteResult = db.inTransaction {
val existing = periodDao.byId(id) ?: return@inTransaction val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
require(endDate == null || !endDate.isBefore(existing.startDate)) { require(endDate == null || !endDate.isBefore(existing.startDate)) {
"a period cannot end ($endDate) before it started (${existing.startDate})" "a period cannot end ($endDate) before it started (${existing.startDate})"
} }
periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant())) periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant()))
PeriodWriteResult.Updated
} }
/** /**
@ -152,21 +161,32 @@ class CycleRepository internal constructor(
* §14: health history is never modified silently even by its owner, the * §14: health history is never modified silently even by its owner, the
* change is recorded as a change. * change is recorded as a change.
*/ */
suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?) = db.inTransaction { suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?): PeriodWriteResult =
val existing = periodDao.byId(id) ?: return@inTransaction db.inTransaction {
require(endDate == null || !endDate.isBefore(startDate)) { val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
"a period cannot end ($endDate) before it started ($startDate)" require(endDate == null || !endDate.isBefore(startDate)) {
"a period cannot end ($endDate) before it started ($startDate)"
}
// Same UNIQUE constraint, same reason to answer with a value: moving
// a record onto a date another record already holds is a question
// only the user can settle. Merging would delete a period they
// entered, and throwing would take the app down.
periodDao.byStartDate(startDate)?.let { clash ->
if (clash.id != id) return@inTransaction PeriodWriteResult.Conflict(clash.id)
}
periodDao.update(
existing.copy(
startDate = startDate,
endDate = endDate,
source = PeriodRecordSource.EDITED.name,
updatedAt = clock.instant(),
),
)
snapshotForecast(basedOnPeriodId = id)
PeriodWriteResult.Updated
} }
periodDao.update(
existing.copy(
startDate = startDate,
endDate = endDate,
source = PeriodRecordSource.EDITED.name,
updatedAt = clock.instant(),
),
)
snapshotForecast(basedOnPeriodId = id)
}
suspend fun deletePeriod(id: Long) = db.inTransaction { suspend fun deletePeriod(id: Long) = db.inTransaction {
periodDao.deleteById(id) periodDao.deleteById(id)

View File

@ -0,0 +1,45 @@
package dev.privacyllc.period.core.data
/**
* What a write to the period history did.
*
* ## Why this exists rather than an exception
*
* `period_records.startDate` is UNIQUE, and inserts ABORT rather than REPLACE
* both deliberate, so a duplicate cannot silently destroy the original row.
* The consequence was a defect found on a device: tapping **Started today**
* twice threw `SQLiteConstraintException` out of `viewModelScope.launch`, which
* has no handler, and the app died on the most ordinary interaction it has.
*
* The mistake was treating "already recorded" as an error. It is not one it
* is a completely reasonable thing for a person to do, twice, when they are not
* sure the first tap registered. The database constraint is right; the API
* around it was wrong to make an exception the only way to say so.
*
* So the ordinary outcomes are values. A genuine fault a full disk, a corrupt
* database still throws, because that is not something a caller can sensibly
* carry on from.
*/
sealed interface PeriodWriteResult {
/** A new record was written. */
data class Added(val id: Long) : PeriodWriteResult
/** That date was already recorded. Nothing changed, and nothing is wrong. */
data class AlreadyRecorded(val id: Long) : PeriodWriteResult
/** An existing record was changed. */
data object Updated : PeriodWriteResult
/**
* The edit would have collided with a different record on that date.
*
* Refused rather than merged: two records on one day is a data question
* only the user can answer, and picking for them would delete a period they
* entered.
*/
data class Conflict(val existingId: Long) : PeriodWriteResult
/** The record was gone by the time the write ran. */
data object NotFound : PeriodWriteResult
}

View File

@ -168,7 +168,7 @@ class CycleRepositoryTest {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@Test fun `editing a record marks it as edited rather than changing it silently`() = runTest { @Test fun `editing a record marks it as edited rather than changing it silently`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
repo.editPeriod(id, LocalDate.of(2026, 8, 3), null) repo.editPeriod(id, LocalDate.of(2026, 8, 3), null)
val p = repo.confirmedPeriods.first().single() val p = repo.confirmedPeriods.first().single()
@ -178,7 +178,7 @@ class CycleRepositoryTest {
} }
@Test fun `a period cannot be made to end before it started`() = runTest { @Test fun `a period cannot be made to end before it started`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id
var refused = false var refused = false
try { try {
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1)) repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1))
@ -190,7 +190,7 @@ class CycleRepositoryTest {
} }
@Test fun `an ongoing period has a null end rather than an invented one`() = runTest { @Test fun `an ongoing period has a null end rather than an invented one`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id
assertNull(repo.confirmedPeriods.first().single().endDate) assertNull(repo.confirmedPeriods.first().single().endDate)
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14)) repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14))
@ -296,6 +296,59 @@ class CycleRepositoryTest {
assertEquals(1, db.predictionRecordDao().unscored().size) assertEquals(1, db.predictionRecordDao().unscored().size)
} }
// -----------------------------------------------------------------------
// Regression: logging the same day twice crashed the app on a device
// -----------------------------------------------------------------------
@Test fun `logging the same day twice is not an error and does not duplicate`() = runTest {
// Found by tapping "Started today" twice on an emulator:
// SQLiteConstraintException out of viewModelScope, process dead. The
// constraint is right — a duplicate must not overwrite the original —
// but "already recorded" is an ordinary thing for a person to do, not
// a fault, and it must never be an exception.
val first = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18))
assertTrue(first is PeriodWriteResult.Added)
val second = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18))
assertTrue("a repeat tap must not throw", second is PeriodWriteResult.AlreadyRecorded)
assertEquals((first as PeriodWriteResult.Added).id, (second as PeriodWriteResult.AlreadyRecorded).id)
assertEquals("and must not duplicate the record", 1, repo.confirmedPeriods.first().size)
}
@Test fun `editing onto an occupied date is refused rather than crashing or merging`() = runTest {
seed("2026-06-01", "2026-06-30")
val first = repo.confirmedPeriods.first().first()
val result = repo.editPeriod(first.id, LocalDate.of(2026, 6, 30), null)
// Merging would delete a period the user entered; throwing would take
// the app down. Neither is ours to choose — it is refused and reported.
assertTrue(result is PeriodWriteResult.Conflict)
assertEquals(2, repo.confirmedPeriods.first().size)
assertEquals(
"the record must be untouched after a refused edit",
LocalDate.of(2026, 6, 1),
repo.confirmedPeriods.first().first().startDate,
)
}
@Test fun `editing a record onto its own date is allowed`() = runTest {
val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
// Changing only the end date must not read as a collision with itself.
assertTrue(repo.editPeriod(id, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 5))
is PeriodWriteResult.Updated)
assertEquals(LocalDate.of(2026, 8, 5), repo.confirmedPeriods.first().single().endDate)
}
@Test fun `writing to a record that is gone reports it rather than throwing`() = runTest {
val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
repo.deletePeriod(id)
assertEquals(PeriodWriteResult.NotFound, repo.editPeriod(id, LocalDate.of(2026, 8, 2), null))
assertEquals(PeriodWriteResult.NotFound, repo.setPeriodEnd(id, LocalDate.of(2026, 8, 5)))
}
@Test fun `accuracy arithmetic is signed for the user and absolute for the average`() { @Test fun `accuracy arithmetic is signed for the user and absolute for the average`() {
// predicted actual: negative is early, positive is late. // predicted actual: negative is early, positive is late.
val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3)) val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3))

View File

@ -86,6 +86,30 @@ silent data loss on update — here, a user's entire cycle history gone with no
error and no way back. A missing migration must be a crash in testing rather error and no way back. A missing migration must be a crash in testing rather
than a wipe in production. than a wipe in production.
### Ordinary outcomes are values; only faults are exceptions
`period_records.startDate` is UNIQUE and inserts ABORT rather than REPLACE, so a
duplicate cannot destroy the original row. Both are right. The API around them
was not: `confirmPeriodStart` let `SQLiteConstraintException` out, and
`viewModelScope.launch` has no handler, so **tapping "Started today" twice
killed the app** — found on a device, not in a test.
The mistake was treating *already recorded* as an error. It is a completely
reasonable thing for a person to do twice when they are unsure the first tap
registered. So the period writes return `PeriodWriteResult``Added`,
`AlreadyRecorded`, `Updated`, `Conflict`, `NotFound` — and a genuine fault (full
disk, corrupt database) still throws, because that is not something a caller can
carry on from.
`Conflict` is refused rather than resolved: moving a record onto a date another
record already holds is a question only the user can settle, and merging would
delete a period they entered.
The ViewModel also installs a `CoroutineExceptionHandler` as a backstop. In a
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.
### One forecast stands at a time, and backfill is not a prediction ### One forecast stands at a time, and backfill is not a prediction
Two rules about `prediction_records` that are easy to get wrong and expensive to Two rules about `prediction_records` that are easy to get wrong and expensive to

View File

@ -20,12 +20,14 @@ room = "2.8.4"
sqlite = "2.7.0" sqlite = "2.7.0"
robolectric = "4.16.1" robolectric = "4.16.1"
androidxTestCore = "1.7.0" androidxTestCore = "1.7.0"
testRunner = "1.7.0"
datastore = "1.2.1" datastore = "1.2.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
@ -56,6 +58,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
[plugins] [plugins]