feat: Insights, and most of it is about staying quiet

§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
This commit is contained in:
null 2026-08-18 04:04:49 -05:00
parent 9d2d15038c
commit 19edf4c920
5 changed files with 465 additions and 3 deletions

View File

@ -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<Int>) {
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<PeriodRecord> {
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)),
),
)
}

View File

@ -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<InsightsUiState> =
combine(repository.confirmedPeriods, repository.accuracy) { periods, accuracy ->
InsightsUiState(insights = CycleInsights.from(periods), accuracy = accuracy)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), InsightsUiState())
}

View File

@ -36,6 +36,7 @@ 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.calendar.CalendarScreen 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.onboarding.OnboardingScreen
import dev.privacyllc.period.feature.today.TodayScreen import dev.privacyllc.period.feature.today.TodayScreen
@ -109,11 +110,12 @@ fun PeriodApp() {
) { ) {
composable(PeriodDestination.TODAY.route) { TodayScreen() } composable(PeriodDestination.TODAY.route) { TodayScreen() }
composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } composable(PeriodDestination.CALENDAR.route) { CalendarScreen() }
composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() }
// Insights and Settings arrive in Batches 03 and 06. They say // Settings arrives in Batch 06. It says "not built yet" rather than
// "not built yet" rather than showing a convincing mock. // showing a convincing mock.
PeriodDestination.entries PeriodDestination.entries
.filter { it != PeriodDestination.TODAY && it != PeriodDestination.CALENDAR } .filter { it == PeriodDestination.SETTINGS }
.forEach { destination -> .forEach { destination ->
composable(destination.route) { composable(destination.route) {
PlaceholderScreen(stringResource(destination.labelRes)) PlaceholderScreen(stringResource(destination.labelRes))

View File

@ -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<Int>,
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<PeriodRecord>): 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 2961 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<Int>): 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)
}
}
}

View File

@ -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<PeriodRecord> {
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)
}
}