diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt new file mode 100644 index 0000000..c3851c2 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt @@ -0,0 +1,376 @@ +package dev.privacyllc.period.feature.calendar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +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.designsystem.PeriodTheme +import dev.privacyllc.period.designsystem.PeriodThemeDefaults +import dev.privacyllc.period.designsystem.art.ConfirmedPeriodMarker +import dev.privacyllc.period.designsystem.art.FertileWindowMarker +import dev.privacyllc.period.designsystem.art.OvulationMarker +import dev.privacyllc.period.designsystem.art.PredictedPeriodMarker +import dev.privacyllc.period.domain.prediction.CalendarDay +import dev.privacyllc.period.domain.prediction.CalendarMarks +import dev.privacyllc.period.domain.prediction.DayMark +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +/** + * PRODUCT_PLAN.md §26. + * + * The rule the whole screen is built around: **states differ in shape, not only + * in colour**, and predicted never looks like a lighter confirmed. A calendar + * whose meaning is carried by hue alone says nothing to a colourblind user, + * nothing in greyscale, and nothing in the screenshot attached to a bug report. + * + * The legend is not decoration either. Four shapes need naming once, and a + * legend is cheaper than four users working it out. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen(viewModel: CalendarViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val selected by viewModel.selected.collectAsStateWithLifecycle() + + selected?.let { day -> + DayActionSheet( + day = day, + onLogPeriod = { viewModel.logPeriodStart(day.date) }, + onLogSpotting = { viewModel.logSpotting(day.date) }, + onRemove = { viewModel.removeEntry(day) }, + onDismiss = viewModel::clearSelection, + ) + } + + CalendarContent( + state = state, + onPrevious = viewModel::previousMonth, + onNext = viewModel::nextMonth, + onSelect = viewModel::select, + onMessageShown = viewModel::messageShown, + ) +} + +@Composable +private fun CalendarContent( + state: CalendarUiState, + onPrevious: () -> Unit, + onNext: () -> Unit, + onSelect: (CalendarDay) -> Unit, + onMessageShown: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + MonthHeader(state.month, state.canGoForward, onPrevious, onNext) + Spacer(Modifier.height(8.dp)) + WeekdayRow() + Spacer(Modifier.height(4.dp)) + MonthGrid(state, onSelect) + Spacer(Modifier.height(24.dp)) + Legend() + + state.message?.let { + Spacer(Modifier.height(16.dp)) + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(it, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onMessageShown) { Text("OK") } + } + } + } + } +} + +@Composable +private fun MonthHeader(month: YearMonth, canGoForward: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious) { + Icon(Icons.Filled.ChevronLeft, contentDescription = "Previous month") + } + Text(month.format(monthFormat), style = MaterialTheme.typography.titleLarge) + IconButton(onClick = onNext, enabled = canGoForward) { + Icon(Icons.Filled.ChevronRight, contentDescription = "Next month") + } + } +} + +@Composable +private fun WeekdayRow() { + Row(Modifier.fillMaxWidth()) { + listOf("M", "T", "W", "T", "F", "S", "S").forEachIndexed { i, label -> + Text( + label, + modifier = Modifier + .weight(1f) + // The initials repeat, so a screen reader gets the full name. + .clearAndSetSemantics { contentDescription = fullWeekdayNames[i] }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private val fullWeekdayNames = + listOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + +@Composable +private fun MonthGrid(state: CalendarUiState, onSelect: (CalendarDay) -> Unit) { + val cells = List(state.leadingBlanks) { null } + state.days + cells.chunked(7).forEach { week -> + Row(Modifier.fillMaxWidth()) { + week.forEach { day -> + Box(Modifier.weight(1f).aspectRatio(1f), contentAlignment = Alignment.Center) { + if (day != null) DayCell(day, onSelect) + } + } + repeat(7 - week.size) { Spacer(Modifier.weight(1f)) } + } + } +} + +@Composable +private fun DayCell(day: CalendarDay, onSelect: (CalendarDay) -> Unit) { + Box( + Modifier + .fillMaxSize() + .padding(3.dp) + .clip(CircleShape) + .clickable { onSelect(day) } + .clearAndSetSemantics { contentDescription = day.accessibilityLabel }, + contentAlignment = Alignment.Center, + ) { + // Today is an outline around the whole cell rather than an underline. + // + // The underline was the first attempt and it collided with the spotting + // dot, which also sits low in the cell — on the one day that was both + // today and spotting, the two marks merged into an unreadable smudge. + // A ring around the cell cannot overlap anything drawn inside it. + if (day.isToday) { + Box( + Modifier + .fillMaxSize() + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + shape = CircleShape, + ), + ) + } + Marker(day.mark) + Text( + "${day.date.dayOfMonth}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal, + color = contentColorFor(day.mark), + ) + + } +} + +@Composable +private fun Marker(mark: DayMark) { + val cycle = PeriodThemeDefaults.cycleColors + val dp = 40.dp + when (mark) { + DayMark.CONFIRMED_PERIOD -> ConfirmedPeriodMarker(cycle.periodConfirmed, dp) + DayMark.PREDICTED_PERIOD -> PredictedPeriodMarker(cycle.periodPredicted, dp) + DayMark.FERTILE_WINDOW -> FertileWindowMarker(cycle.fertileWindow, dp) + DayMark.OVULATION -> OvulationMarker(cycle.ovulation, dp * 0.5f) + DayMark.SPOTTING -> SpottingMarker(cycle.periodConfirmed, dp) + DayMark.NONE -> Unit + } +} + +/** + * A small dot low in the cell. + * + * Deliberately not a ring of any kind: the other four marks are all circular + * outlines or fills at cell size, and a fifth circle differing only in diameter + * is the weakest possible distinction. A small dot sitting below the numeral is + * a different *kind* of mark, which is what survives greyscale. + */ +@Composable +private fun SpottingMarker(color: Color, size: androidx.compose.ui.unit.Dp) { + Box(Modifier.size(size), contentAlignment = Alignment.BottomCenter) { + Box( + Modifier + .padding(bottom = 6.dp) + .size(7.dp) + .clip(CircleShape) + .background(color), + ) + } +} + +@Composable +private fun contentColorFor(mark: DayMark): Color = when (mark) { + // Only the solid fill needs inverted text; every other marker is an outline + // that the numeral sits inside. + DayMark.CONFIRMED_PERIOD -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface +} + +@Composable +private fun Legend() { + Text("What the marks mean", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(8.dp)) + listOf( + DayMark.CONFIRMED_PERIOD to "Period you logged", + DayMark.PREDICTED_PERIOD to "Period predicted", + DayMark.SPOTTING to "Spotting", + DayMark.FERTILE_WINDOW to "Estimated fertile window", + DayMark.OVULATION to "Estimated ovulation", + ).forEach { (mark, label) -> + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { Marker(mark) } + Spacer(Modifier.size(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(8.dp)) + Text( + "Fertility and ovulation dates are estimates based on cycle history and are not " + + "intended to be used as contraception or as a medical diagnosis.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DayActionSheet( + day: CalendarDay, + onLogPeriod: () -> Unit, + onLogSpotting: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column(Modifier.fillMaxWidth().padding(24.dp)) { + Text(day.date.format(dayFormat), style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + when (day.mark) { + DayMark.CONFIRMED_PERIOD, DayMark.SPOTTING -> + OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { + Text(if (day.mark == DayMark.SPOTTING) "Remove spotting" else "Remove this period") + } + else -> { + androidx.compose.material3.Button( + onClick = onLogPeriod, + modifier = Modifier.fillMaxWidth(), + ) { Text("Period started this day") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onLogSpotting, modifier = Modifier.fillMaxWidth()) { + Text("Spotting this day") + } + } + } + Spacer(Modifier.height(16.dp)) + } + } +} + +private val monthFormat = DateTimeFormatter.ofPattern("MMMM yyyy") +private val dayFormat = DateTimeFormatter.ofPattern("EEEE d MMMM yyyy") + +// --------------------------------------------------------------------------- + +@Preview(name = "Calendar · light", showBackground = true, heightDp = 900) +@Preview( + name = "Calendar · dark", + showBackground = true, + heightDp = 900, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun PreviewCalendar() { + val month = YearMonth.of(2026, 8) + val today = LocalDate.of(2026, 8, 18) + val days = CalendarMarks.forMonth( + month = month, + periods = listOf( + dev.privacyllc.period.domain.cycle.PeriodRecord( + 1, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 7), + ), + ), + spotting = listOf(dev.privacyllc.period.domain.cycle.SpottingRecord(1, LocalDate.of(2026, 8, 11))), + forecast = dev.privacyllc.period.domain.prediction.Prediction( + mostLikelyStartDate = LocalDate.of(2026, 8, 31), + windowStart = LocalDate.of(2026, 8, 29), + windowEnd = LocalDate.of(2026, 8, 31), + confidenceScore = 0.7, + confidenceLabel = dev.privacyllc.period.domain.prediction.ConfidenceLabel.HIGH, + modelVersion = "preview", + ), + today = today, + fertileWindow = LocalDate.of(2026, 8, 14)..LocalDate.of(2026, 8, 19), + ovulation = LocalDate.of(2026, 8, 18), + ) + PeriodTheme { + CalendarContent( + state = CalendarUiState(month = month, days = days, today = today), + onPrevious = {}, onNext = {}, onSelect = {}, onMessageShown = {}, + ) + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..c34592f --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt @@ -0,0 +1,125 @@ +package dev.privacyllc.period.feature.calendar + +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.domain.prediction.CalendarDay +import dev.privacyllc.period.domain.prediction.CalendarMarks +import dev.privacyllc.period.domain.prediction.DayMark +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.LocalDate +import java.time.YearMonth +import javax.inject.Inject + +data class CalendarUiState( + val month: YearMonth = YearMonth.of(2026, 1), + val days: List = emptyList(), + val today: LocalDate = LocalDate.ofEpochDay(0), + val message: String? = null, +) { + /** Blank leading cells so the 1st lands under its weekday. Monday-first. */ + val leadingBlanks: Int + get() = days.firstOrNull()?.date?.dayOfWeek?.value?.minus(1) ?: 0 + + /** Nothing ahead of today can be logged, so next-month browsing stops there. */ + val canGoForward: Boolean get() = month < YearMonth.from(today).plusMonths(FORWARD_MONTHS) + + private companion object { + /** One month ahead, so a predicted period near a month boundary is visible. */ + const val FORWARD_MONTHS = 1L + } +} + +@HiltViewModel +class CalendarViewModel @Inject constructor( + private val repository: CycleRepository, + private val clock: Clock, +) : ViewModel() { + + private val month = MutableStateFlow(YearMonth.from(LocalDate.now(clock))) + private val message = MutableStateFlow(null) + + private val _selected = MutableStateFlow(null) + val selected: StateFlow = _selected.asStateFlow() + + private val handler = CoroutineExceptionHandler { _, e -> + message.value = "Could not save that (${e::class.simpleName}). Nothing was changed." + } + + val state: StateFlow = + combine( + month, + repository.confirmedPeriods, + repository.spotting, + repository.forecast, + message, + ) { m, periods, spotting, forecast, msg -> + CalendarUiState( + month = m, + days = CalendarMarks.forMonth( + month = m, + periods = periods, + spotting = spotting, + forecast = forecast, + today = LocalDate.now(clock), + ), + today = LocalDate.now(clock), + message = msg, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CalendarUiState()) + + fun previousMonth() { month.value = month.value.minusMonths(1) } + fun nextMonth() { if (state.value.canGoForward) month.value = month.value.plusMonths(1) } + + fun select(day: CalendarDay) { + // A future day cannot be logged — nothing has happened yet — so tapping + // one does nothing rather than opening a sheet whose every action is + // disabled. + if (day.date.isAfter(LocalDate.now(clock))) return + _selected.value = day + } + + fun clearSelection() { _selected.value = null } + fun messageShown() { message.value = null } + + fun logPeriodStart(date: LocalDate) = act { + repository.confirmPeriodStart(date) + message.value = "Logged ✓ Your predictions have been updated." + } + + fun logSpotting(date: LocalDate) = act { + repository.recordSpotting(date) + message.value = "Spotting logged. Your cycle is unchanged." + } + + fun removeEntry(day: CalendarDay) = act { + when (day.mark) { + DayMark.SPOTTING -> repository.removeSpotting(day.date) + DayMark.CONFIRMED_PERIOD -> + repository.confirmedPeriods.first() + .firstOrNull { it.startDate == day.date } + ?.let { repository.deletePeriod(it.id) } + // Only a period's START day can be removed here. Deleting the + // whole record because the user tapped its third day would + // remove more than they asked for. + ?: run { message.value = "Only the first day of a period can be removed here." } + else -> Unit + } + if (message.value == null) message.value = "Removed." + } + + private fun act(block: suspend () -> Unit) = viewModelScope.launch(handler) { + block() + _selected.value = null + } +} 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 43053b8..ce08876 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.onboarding.OnboardingScreen import dev.privacyllc.period.feature.today.TodayScreen @@ -107,11 +108,12 @@ fun PeriodApp() { modifier = Modifier.padding(innerPadding), ) { composable(PeriodDestination.TODAY.route) { TodayScreen() } + composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } - // Calendar, Insights and Settings arrive in Batches 03 and 06. They - // say "not built yet" rather than showing a convincing mock. + // 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 } + .filter { it != PeriodDestination.TODAY && it != PeriodDestination.CALENDAR } .forEach { destination -> composable(destination.route) { PlaceholderScreen(stringResource(destination.labelRes)) diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt new file mode 100644 index 0000000..e5623a1 --- /dev/null +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt @@ -0,0 +1,119 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.SpottingRecord +import java.time.LocalDate + +/** + * What a calendar day is, at most one thing. + * + * Ordered by precedence, highest first. A day can qualify for several — the + * predicted window can overlap a fertile window on a short cycle — and showing + * two markers on one square makes both unreadable at the size a month grid + * gives you. + * + * The rule for the ordering: **a fact outranks an estimate, and the more + * specific estimate outranks the vaguer one.** + */ +enum class DayMark { + /** Recorded by the user. The only certain one. */ + CONFIRMED_PERIOD, + + /** Recorded by the user, and deliberately not a period. */ + SPOTTING, + + /** Inside the forecast window. */ + PREDICTED_PERIOD, + + /** The single estimated ovulation day. Batch 04. */ + OVULATION, + + /** Inside the estimated fertile window. Batch 04. */ + FERTILE_WINDOW, + + NONE, +} + +data class CalendarDay( + val date: LocalDate, + val mark: DayMark, + val isToday: Boolean, +) { + /** + * What a screen reader says. + * + * §43: a calendar that reads as a grid of bare numbers carries none of its + * information. The marker shape is what a sighted user reads; this is the + * same fact for everybody else, and it is not optional. + */ + val accessibilityLabel: String = buildString { + append(date.dayOfMonth) + when (mark) { + DayMark.CONFIRMED_PERIOD -> append(", period") + DayMark.SPOTTING -> append(", spotting") + DayMark.PREDICTED_PERIOD -> append(", period predicted") + DayMark.OVULATION -> append(", estimated ovulation") + DayMark.FERTILE_WINDOW -> append(", estimated fertile window") + DayMark.NONE -> Unit + } + if (isToday) append(", today") + } +} + +object CalendarMarks { + + /** + * Mark every day in [month]. + * + * Everything is derived per call rather than stored. A `calendar_days` table + * would be a second copy of facts the period records and the forecast + * already hold, and it would be stale the moment either changed. + */ + fun forMonth( + month: java.time.YearMonth, + periods: List, + spotting: List, + forecast: Prediction?, + today: LocalDate, + fertileWindow: ClosedRange? = null, + ovulation: LocalDate? = null, + ): List { + val confirmed = buildSet { + periods.filter { it.isConfirmed }.forEach { p -> + // An unclosed period marks its start day only. Filling forward + // to today would draw a period the user never said was still + // running — the calendar would be asserting it. + val end = p.endDate ?: p.startDate + var d = p.startDate + while (!d.isAfter(end)) { add(d); d = d.plusDays(1) } + } + } + val spotted = spotting.map { it.date }.toSet() + + return (1..month.lengthOfMonth()).map { day -> + val date = month.atDay(day) + CalendarDay( + date = date, + mark = markFor(date, confirmed, spotted, forecast, fertileWindow, ovulation), + isToday = date == today, + ) + } + } + + private fun markFor( + date: LocalDate, + confirmed: Set, + spotted: Set, + forecast: Prediction?, + fertileWindow: ClosedRange?, + ovulation: LocalDate?, + ): DayMark = when { + date in confirmed -> DayMark.CONFIRMED_PERIOD + date in spotted -> DayMark.SPOTTING + forecast != null && date >= forecast.windowStart && date <= forecast.windowEnd -> + DayMark.PREDICTED_PERIOD + ovulation != null && date == ovulation -> DayMark.OVULATION + fertileWindow != null && date in fertileWindow -> DayMark.FERTILE_WINDOW + else -> DayMark.NONE + } +} diff --git a/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt new file mode 100644 index 0000000..86028fe --- /dev/null +++ b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt @@ -0,0 +1,118 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.SpottingRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +class CalendarMarksTest { + + private val month = YearMonth.of(2026, 8) + private val today = LocalDate.of(2026, 8, 18) + + private fun forecast(from: LocalDate, to: LocalDate) = Prediction( + mostLikelyStartDate = from.plusDays((to.toEpochDay() - from.toEpochDay()) / 2), + windowStart = from, + windowEnd = to, + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + + private fun marks( + periods: List = emptyList(), + spotting: List = emptyList(), + prediction: Prediction? = null, + fertile: ClosedRange? = null, + ovulation: LocalDate? = null, + ) = CalendarMarks.forMonth(month, periods, spotting, prediction, today, fertile, ovulation) + .associateBy { it.date.dayOfMonth } + + @Test fun `every day of the month is present exactly once`() { + val days = marks() + assertEquals(31, days.size) + assertTrue(days.values.all { it.mark == DayMark.NONE }) + } + + @Test fun `a closed period marks every day it covers, inclusive`() { + val days = marks( + listOf(PeriodRecord(1, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 6))), + ) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(3).mark) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(6).mark) + assertEquals(DayMark.NONE, days.getValue(2).mark) + assertEquals(DayMark.NONE, days.getValue(7).mark) + } + + @Test fun `an unclosed period marks only its start`() { + // Filling forward to today would draw days the user never said were + // period days. The calendar must not assert that. + val days = marks(listOf(PeriodRecord(1, LocalDate.of(2026, 8, 15), null))) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(15).mark) + assertEquals(DayMark.NONE, days.getValue(16).mark) + } + + @Test fun `the whole forecast window is marked as predicted`() { + val days = marks(prediction = forecast(LocalDate.of(2026, 8, 20), LocalDate.of(2026, 8, 24))) + (20..24).forEach { assertEquals("day $it", DayMark.PREDICTED_PERIOD, days.getValue(it).mark) } + assertEquals(DayMark.NONE, days.getValue(19).mark) + assertEquals(DayMark.NONE, days.getValue(25).mark) + } + + @Test fun `a confirmed period beats a prediction over the same day`() { + val days = marks( + periods = listOf(PeriodRecord(1, LocalDate.of(2026, 8, 21), LocalDate.of(2026, 8, 23))), + prediction = forecast(LocalDate.of(2026, 8, 20), LocalDate.of(2026, 8, 24)), + ) + // §26: predicted and confirmed must never look identical, and where both + // apply the fact is what the user needs to see. + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(21).mark) + assertEquals(DayMark.PREDICTED_PERIOD, days.getValue(20).mark) + } + + @Test fun `spotting is its own mark and never a period`() { + val days = marks(spotting = listOf(SpottingRecord(1, LocalDate.of(2026, 8, 10)))) + assertEquals(DayMark.SPOTTING, days.getValue(10).mark) + assertTrue(days.values.none { it.mark == DayMark.CONFIRMED_PERIOD }) + } + + @Test fun `ovulation outranks the fertile window it sits inside`() { + val days = marks( + fertile = LocalDate.of(2026, 8, 8)..LocalDate.of(2026, 8, 13), + ovulation = LocalDate.of(2026, 8, 12), + ) + assertEquals(DayMark.OVULATION, days.getValue(12).mark) + assertEquals(DayMark.FERTILE_WINDOW, days.getValue(11).mark) + } + + @Test fun `fertility is absent until it is estimated`() { + assertTrue(marks().values.none { it.mark == DayMark.FERTILE_WINDOW || it.mark == DayMark.OVULATION }) + } + + @Test fun `today is flagged and says so to a screen reader`() { + val days = marks(periods = listOf(PeriodRecord(1, today, today))) + val d = days.getValue(18) + assertTrue(d.isToday) + assertEquals("18, period, today", d.accessibilityLabel) + } + + @Test fun `every marked day describes itself in words`() { + // §43: a grid of bare numbers carries none of the calendar's information. + val days = marks( + periods = listOf(PeriodRecord(1, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 5))), + spotting = listOf(SpottingRecord(1, LocalDate.of(2026, 8, 10))), + prediction = forecast(LocalDate.of(2026, 8, 28), LocalDate.of(2026, 8, 30)), + fertile = LocalDate.of(2026, 8, 14)..LocalDate.of(2026, 8, 16), + ovulation = LocalDate.of(2026, 8, 15), + ) + assertEquals("3, period", days.getValue(3).accessibilityLabel) + assertEquals("10, spotting", days.getValue(10).accessibilityLabel) + assertEquals("28, period predicted", days.getValue(28).accessibilityLabel) + assertEquals("15, estimated ovulation", days.getValue(15).accessibilityLabel) + assertEquals("14, estimated fertile window", days.getValue(14).accessibilityLabel) + assertEquals("1", days.getValue(1).accessibilityLabel) + } +}