From 9d2d15038c40de2778c9f2cd3502ef1693e29648 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 04:01:47 -0500 Subject: [PATCH] feat: calendar whose states survive greyscale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §26's four states plus spotting, distinguished by SHAPE: a solid disc for a period you logged, a dotted ring for one predicted, a small low dot for spotting, a continuous ring for the fertile window, a four-pointed mark for ovulation. Verified the way the requirement is written — the screenshot was converted to greyscale and every mark is still tellable apart. Predicted is never a lighter confirmed. §26 forbids them looking identical, and opacity alone fails exactly when the screen is dim or the user is not looking closely. TWO PROBLEMS THE GREYSCALE CHECK FOUND Today was drawn as an underline under the numeral. Spotting is a dot low in the same cell. On the one day that was both — which was, of course, the day I looked at — they merged into an unreadable smudge. Today is now an outline around the whole cell, which cannot overlap anything drawn inside it. The legend's spotting row was misaligned for the same reason: a mark anchored to the bottom of its box next to vertically centred text. Fixed by moving the dot up off the floor of the cell. Neither was visible in the colour screenshot. Both were obvious in greyscale, which is the argument for doing the check the way §43 describes rather than assuming shapes differ because they were designed to. Marks are derived per call, never stored: a calendar_days table would be a second copy of facts the period records and the forecast already hold, stale the moment either changed. An unclosed period marks only its start day, because filling forward to today would draw days the user never said were period days. Every day carries a content description — "18, spotting, today" — because §43 is right that a grid of bare numerals carries none of a calendar's information. Weekday initials repeat, so those are labelled too. Tapping a day logs or removes an entry there; future days do nothing rather than opening a sheet whose every action is disabled. 145 tests, all passing. ./gradlew check green. closes #19 --- .../period/feature/calendar/CalendarScreen.kt | 376 ++++++++++++++++++ .../feature/calendar/CalendarViewModel.kt | 125 ++++++ .../privacyllc/period/navigation/PeriodApp.kt | 8 +- .../period/domain/prediction/CalendarMarks.kt | 119 ++++++ .../domain/prediction/CalendarMarksTest.kt | 118 ++++++ 5 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt create mode 100644 domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt create mode 100644 domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt 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) + } +}