From 19edf4c9202429be5c7e49070895349936ae7b21 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 04:04:49 -0500 Subject: [PATCH] feat: Insights, and most of it is about staying quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §27's purpose is one sentence — show the user what the app has learned — which means the screen has to be honest about how little that sometimes is. Every figure is absent rather than approximated below the history that supports it, because the easiest way to overstate accuracy is to average two numbers and print a decimal place. - one interval is an anecdote, not an average: no "average cycle" until two - accuracy figures wait for three scored forecasts, and say why they are waiting rather than showing a mean of one - §27's learning copy is chosen by what the data supports, never by mood. "Personalized to your cycle" is a claim, and it appears only when there are enough confirmed cycles for the forecast to genuinely be hers A BUG THE OUTLIER TEST CAUGHT The typical-range quartiles were indexed off `size` instead of `size - 1`, which on an even-length list puts the upper index on the largest value. A history of 29, 28, 30, 29, 61, 29 reported a "typical range" of 29–61 — describing a regular cycle as wildly erratic, on the one screen whose whole job is to say what has been learned about her. Now 29–30. Nothing on this screen leaves the device. §46 names prediction_error= among the values that must never become an analytics event, and this is exactly the screen that would tempt somebody to send one. There is no network call in these files and there must never be. 153 tests, all passing. ./gradlew check green. closes #20 --- .../period/feature/insights/InsightsScreen.kt | 236 ++++++++++++++++++ .../feature/insights/InsightsViewModel.kt | 36 +++ .../privacyllc/period/navigation/PeriodApp.kt | 8 +- .../period/domain/cycle/CycleInsights.kt | 101 ++++++++ .../period/domain/cycle/CycleInsightsTest.kt | 87 +++++++ 5 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt create mode 100644 domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt create mode 100644 domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt new file mode 100644 index 0000000..6d900f7 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt @@ -0,0 +1,236 @@ +package dev.privacyllc.period.feature.insights + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.core.data.PredictionAccuracy +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.designsystem.art.LearningIllustration +import dev.privacyllc.period.domain.cycle.CycleInsights +import dev.privacyllc.period.domain.cycle.LearningStage +import dev.privacyllc.period.domain.cycle.PeriodRecord +import java.time.LocalDate + +/** + * §27. Its purpose is one sentence: **show the user what the app has learned.** + * + * Which means the screen has to be honest about how little that sometimes is. + * Every figure here is absent rather than approximated when there is not enough + * history — §27 ends with *do not overstate accuracy*, and the easiest way to + * overstate is to average two numbers and print a decimal place. + * + * Nothing on this screen leaves the device. §46 names `prediction_error=` as a + * value that must never become an analytics event, and this is exactly the + * screen that would tempt somebody to send one. + */ +@Composable +fun InsightsScreen(viewModel: InsightsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + InsightsContent(state) +} + +@Composable +private fun InsightsContent(state: InsightsUiState) { + val i = state.insights + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + LearningBanner(i.stage, i.cycleCount) + + if (i.stage != LearningStage.NO_DATA) { + Spacer(Modifier.height(16.dp)) + CycleCard(i) + } + + if (i.recentCycleLengths.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + RecentCyclesCard(i.recentCycleLengths) + } + + Spacer(Modifier.height(12.dp)) + AccuracyCard(state.accuracy) + } +} + +/** + * §27's learning copy, chosen by what the data supports rather than by mood. + * + * "Personalized to your cycle" is a claim. It appears when there are enough + * confirmed cycles for the forecast to genuinely be hers rather than a default, + * and not before. + */ +@Composable +private fun LearningBanner(stage: LearningStage, cycleCount: Int) { + val (title, body) = when (stage) { + LearningStage.NO_DATA -> + "Nothing learned yet" to "Log a period and this screen will start filling in." + LearningStage.LEARNING -> + "Learning your cycle" to "One cycle recorded. A few more and the predictions get noticeably better." + LearningStage.GETTING_TO_KNOW -> + "Getting to know your pattern" to "$cycleCount cycles recorded. The forecast is starting to be yours." + LearningStage.PERSONALIZED -> + "Personalized to your cycle" to "$cycleCount cycles recorded. Predictions are based on your own history." + } + + Column( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (stage != LearningStage.PERSONALIZED) { + LearningIllustration( + color = MaterialTheme.colorScheme.primary, + accent = MaterialTheme.colorScheme.tertiary, + size = 110.dp, + ) + Spacer(Modifier.height(12.dp)) + } + Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.height(6.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun CycleCard(i: CycleInsights) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Your cycle", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + // Absent rather than approximated. A dash is honest; a number + // computed from one observation is not. + Stat("Average cycle", i.averageCycleDays?.let { "%.1f days".format(it) }) + Stat("Typical range", i.typicalCycleRange?.let { "${it.first}–${it.last} days" }) + Stat("Average period", i.averagePeriodDays?.let { "%.1f days".format(it) }) + } + } +} + +@Composable +private fun RecentCyclesCard(lengths: List) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Recent cycles", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + lengths.forEach { + Text("$it days", style = MaterialTheme.typography.bodyLarge) + } + } + } +} + +/** + * §16's accuracy figures — the ones the document calls a powerful trust feature. + * + * They are also the easiest thing on this screen to lie with, which is why + * `PredictionAccuracy` refuses to report below three scored forecasts and this + * card says so plainly instead of showing a mean of one. + */ +@Composable +private fun AccuracyCard(a: PredictionAccuracy) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Prediction accuracy", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + if (!a.hasEnoughToShow) { + Text( + if (a.scoredCount == 0) { + "Once a predicted period arrives, the app will start scoring itself here." + } else { + "${a.scoredCount} prediction(s) scored so far. Accuracy figures appear " + + "after ${PredictionAccuracy.MINIMUM_TO_SHOW}, so they mean something." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + + a.lastSignedErrorDays?.let { + Stat( + "Last prediction", + when { + it == 0 -> "exact" + it < 0 -> "${-it} day(s) early" + else -> "$it day(s) late" + }, + ) + } + Stat("Average error", a.meanAbsoluteErrorDays?.let { "%.1f days".format(it) }) + Stat("Within one day", "${a.withinOneDay} of your last ${a.scoredCount}") + Stat("Within two days", "${a.withinTwoDays} of your last ${a.scoredCount}") + } + } +} + +@Composable +private fun Stat(label: String, value: String?) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value ?: "—", style = MaterialTheme.typography.bodyLarge) + } +} + +// --------------------------------------------------------------------------- + +private fun previewPeriods(vararg gaps: Long): List { + var cursor = LocalDate.of(2026, 3, 1) + val out = mutableListOf(cursor) + gaps.forEach { cursor = cursor.plusDays(it); out += cursor } + return out.mapIndexed { i, d -> PeriodRecord(i.toLong(), d, d.plusDays(4)) } +} + +@Preview(name = "Insights · learning", showBackground = true, heightDp = 800) +@Composable +private fun PreviewLearning() = PeriodTheme { + InsightsContent(InsightsUiState(insights = CycleInsights.from(previewPeriods(29)))) +} + +@Preview(name = "Insights · personalized", showBackground = true, heightDp = 900) +@Preview( + name = "Insights · personalized dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun PreviewPersonalized() = PeriodTheme { + InsightsContent( + InsightsUiState( + insights = CycleInsights.from(previewPeriods(29, 28, 30, 29, 28, 30)), + accuracy = PredictionAccuracy.from(listOf(-1, 1, 0, 2, -1, 1)), + ), + ) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt new file mode 100644 index 0000000..f8429f5 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsViewModel.kt @@ -0,0 +1,36 @@ +package dev.privacyllc.period.feature.insights + +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.PredictionAccuracy +import dev.privacyllc.period.domain.cycle.CycleInsights +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class InsightsUiState( + val insights: CycleInsights = CycleInsights.from(emptyList()), + val accuracy: PredictionAccuracy = PredictionAccuracy.Empty, +) + +/** + * §27, and everything on it is computed here on the device. + * + * §46 lists `prediction_error=` among the values that must never leave as an + * analytics event, and this is the screen that would tempt somebody to send one. + * There is no network call in this file and there must never be. + */ +@HiltViewModel +class InsightsViewModel @Inject constructor( + repository: CycleRepository, +) : ViewModel() { + + val state: StateFlow = + combine(repository.confirmedPeriods, repository.accuracy) { periods, accuracy -> + InsightsUiState(insights = CycleInsights.from(periods), accuracy = accuracy) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), InsightsUiState()) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index ce08876..b9257bf 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -36,6 +36,7 @@ import androidx.navigation.compose.rememberNavController import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.calendar.CalendarScreen +import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.today.TodayScreen @@ -109,11 +110,12 @@ fun PeriodApp() { ) { composable(PeriodDestination.TODAY.route) { TodayScreen() } composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } + composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() } - // Insights and Settings arrive in Batches 03 and 06. They say - // "not built yet" rather than showing a convincing mock. + // Settings arrives in Batch 06. It says "not built yet" rather than + // showing a convincing mock. PeriodDestination.entries - .filter { it != PeriodDestination.TODAY && it != PeriodDestination.CALENDAR } + .filter { it == PeriodDestination.SETTINGS } .forEach { destination -> composable(destination.route) { PlaceholderScreen(stringResource(destination.labelRes)) diff --git a/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt new file mode 100644 index 0000000..3c9746d --- /dev/null +++ b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/CycleInsights.kt @@ -0,0 +1,101 @@ +package dev.privacyllc.period.domain.cycle + +import kotlin.math.roundToInt + +/** + * How much the app can honestly claim to know. + * + * PRODUCT_PLAN.md §27's copy — "Learning your cycle", "Getting to know your + * pattern", "Personalized to your cycle" — is a claim about the model, so which + * one is shown has to be a fact about the data rather than a mood. + */ +enum class LearningStage { + /** Nothing to learn from yet. */ + NO_DATA, + + /** One or two intervals. A forecast exists; a *pattern* does not. */ + LEARNING, + + /** Enough to see a shape, not enough to be sure of it. */ + GETTING_TO_KNOW, + + /** Enough history that the forecast is genuinely hers rather than a default. */ + PERSONALIZED, +} + +/** + * What Insights shows — §27, whose whole purpose is one sentence: **show the + * user what the app has learned.** + * + * Every field is nullable and every one means "not enough data to say" when it + * is null. That is deliberate: §27 ends with *do not overstate accuracy*, and + * the easiest way to overstate is to compute a mean over two numbers and print + * it with a decimal place. + */ +data class CycleInsights( + val cycleCount: Int, + val averageCycleDays: Double?, + val typicalCycleRange: IntRange?, + val averagePeriodDays: Double?, + /** Newest first, for the "recent cycles" list. */ + val recentCycleLengths: List, + val stage: LearningStage, +) { + companion object { + /** Below this there is no average worth printing — one interval is an anecdote. */ + const val MINIMUM_FOR_AVERAGE = 2 + + /** Below this, §27's copy says "getting to know" rather than "personalized". */ + const val PERSONALIZED_FROM = 5 + + const val RECENT_COUNT = 6 + + fun from(periods: List): CycleInsights { + val cycles = periods.toCycles() + val lengths = cycles.map { it.cycleLengthDays }.filter { it in 10..90 } + + val durations = periods.mapNotNull { p -> + p.endDate?.let { (it.toEpochDay() - p.startDate.toEpochDay()).toInt() + 1 } + }.filter { it in 1..14 } + + val stage = when { + lengths.isEmpty() -> LearningStage.NO_DATA + lengths.size < MINIMUM_FOR_AVERAGE -> LearningStage.LEARNING + lengths.size < PERSONALIZED_FROM -> LearningStage.GETTING_TO_KNOW + else -> LearningStage.PERSONALIZED + } + + val enough = lengths.size >= MINIMUM_FOR_AVERAGE + + return CycleInsights( + cycleCount = lengths.size, + averageCycleDays = if (enough) lengths.average() else null, + // The middle of the distribution rather than min..max: one long + // cycle should not be reported as this user's "typical range", + // which would make the app describe her as more erratic than she + // is on the screen whose job is to say what it has learned. + typicalCycleRange = if (enough) typicalRange(lengths) else null, + averagePeriodDays = if (durations.size >= MINIMUM_FOR_AVERAGE) durations.average() else null, + recentCycleLengths = lengths.reversed().take(RECENT_COUNT), + stage = stage, + ) + } + + /** + * The interquartile span, indexed off `size - 1` rather than `size`. + * + * Using `size` puts the upper index one place too high, which on an + * even-length list lands on the largest value — so a history of + * 29, 28, 30, 29, 61, 29 reported a "typical range" of 29–61 and + * described a regular cycle as wildly erratic. Caught by the test that + * says one long cycle must not become the typical range. + */ + private fun typicalRange(lengths: List): IntRange { + val sorted = lengths.sorted() + val last = sorted.lastIndex + val low = sorted[(last * 0.25).roundToInt().coerceIn(0, last)] + val high = sorted[(last * 0.75).roundToInt().coerceIn(0, last)] + return low..maxOf(low, high) + } + } +} diff --git a/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt b/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt new file mode 100644 index 0000000..4c0e082 --- /dev/null +++ b/domain/cycle/src/test/kotlin/dev/privacyllc/period/domain/cycle/CycleInsightsTest.kt @@ -0,0 +1,87 @@ +package dev.privacyllc.period.domain.cycle + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §27's rule, which is easier to state than to keep: **do not overstate what the + * app has learned.** Most of these tests are about staying quiet. + */ +class CycleInsightsTest { + + private fun records(vararg gaps: Long, duration: Long? = null): List { + var cursor = LocalDate.of(2026, 1, 1) + val out = mutableListOf(cursor) + gaps.forEach { cursor = cursor.plusDays(it); out += cursor } + return out.mapIndexed { i, d -> + PeriodRecord(id = i.toLong(), startDate = d, endDate = duration?.let { d.plusDays(it - 1) }) + } + } + + @Test fun `no history says so rather than showing zeroes`() { + val i = CycleInsights.from(emptyList()) + assertEquals(LearningStage.NO_DATA, i.stage) + assertNull(i.averageCycleDays) + assertNull(i.typicalCycleRange) + assertTrue(i.recentCycleLengths.isEmpty()) + } + + @Test fun `one interval is an anecdote, not an average`() { + val i = CycleInsights.from(records(29)) + assertEquals(LearningStage.LEARNING, i.stage) + assertEquals(1, i.cycleCount) + // "Average cycle: 29.0 days" from a single observation is the app + // claiming a pattern it has seen once. + assertNull(i.averageCycleDays) + assertNull(i.typicalCycleRange) + } + + @Test fun `averages appear from two intervals`() { + val i = CycleInsights.from(records(28, 30)) + assertEquals(29.0, i.averageCycleDays!!, 0.001) + assertEquals(LearningStage.GETTING_TO_KNOW, i.stage) + } + + @Test fun `the learning stage tracks the amount of history`() { + assertEquals(LearningStage.GETTING_TO_KNOW, CycleInsights.from(records(29, 28, 30)).stage) + assertEquals( + LearningStage.PERSONALIZED, + CycleInsights.from(records(29, 28, 30, 29, 28)).stage, + ) + } + + @Test fun `recent cycles are newest first and capped`() { + val i = CycleInsights.from(records(21, 22, 23, 24, 25, 26, 27, 28)) + assertEquals(CycleInsights.RECENT_COUNT, i.recentCycleLengths.size) + assertEquals(28, i.recentCycleLengths.first()) + assertEquals(23, i.recentCycleLengths.last()) + } + + @Test fun `one long cycle does not become the typical range`() { + // Reporting 28..61 as "typical" would describe her as far more erratic + // than she is, on the screen whose entire job is to say what has been + // learned about her. + val i = CycleInsights.from(records(29, 28, 30, 29, 61, 29)) + val range = i.typicalCycleRange!! + assertTrue("range was $range", range.last <= 32) + } + + @Test fun `period duration needs more than one closed period`() { + assertNull(CycleInsights.from(records(29, duration = null)).averagePeriodDays) + assertNull(CycleInsights.from(records(29)).averagePeriodDays) + + val i = CycleInsights.from(records(29, 28, duration = 5)) + assertEquals(5.0, i.averagePeriodDays!!, 0.001) + } + + @Test fun `implausible intervals are excluded from what is reported`() { + // A 200-day gap is a user returning after a year, not a cycle, and + // averaging it in would make every figure on the screen wrong. + val i = CycleInsights.from(records(29, 28, 200, 29)) + assertTrue("200 must not appear", i.recentCycleLengths.none { it > 90 }) + assertTrue(i.averageCycleDays!! < 40) + } +}