From 9dc451c2db55249036952158b0a3af163c1415b4 Mon Sep 17 00:00:00 2001 From: dydyamotya Date: Mon, 23 Feb 2026 12:57:41 +0300 Subject: [PATCH] Predict period start days not only for next month This utility is useful for planning your vacation dates --- .../app/business/CalculationsHelper.kt | 61 ++++++++++++++----- .../app/business/ICalculationsHelper.kt | 5 +- .../app/business/IPeriodPrediction.kt | 4 +- .../app/business/OvulationPrediction.kt | 4 +- .../app/business/PeriodPrediction.kt | 10 +-- .../notifications/NotificationScheduler.kt | 2 +- .../mensinator/app/calendar/CalendarScreen.kt | 2 +- .../app/calendar/CalendarViewModel.kt | 4 +- .../app/statistics/StatisticsViewModel.kt | 9 ++- .../mensinator/app/PeriodPredictionTest.kt | 4 +- .../app/business/NotificationSchedulerTest.kt | 19 +++--- 11 files changed, 85 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/mensinator/app/business/CalculationsHelper.kt b/app/src/main/java/com/mensinator/app/business/CalculationsHelper.kt index 4bc3ece4..506c413c 100644 --- a/app/src/main/java/com/mensinator/app/business/CalculationsHelper.kt +++ b/app/src/main/java/com/mensinator/app/business/CalculationsHelper.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import java.time.LocalDate import java.time.temporal.ChronoUnit +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf class CalculationsHelper( private val dbHelper: IPeriodDatabaseHelper, @@ -34,25 +36,19 @@ class CalculationsHelper( // Basic calculation using latest period start dates Log.d("TAG", "Basic calculation") //do basic calculation here - //Use X latest periodstartdates (will return list of X+1) + //Use averageCycleLength function val listPeriodDates = dbHelper.getLatestXPeriodStart(periodHistory) if (listPeriodDates.isEmpty()) { null } else { // Calculate the cycle lengths between consecutive periods - val cycleLengths = mutableListOf() - for (i in 0 until listPeriodDates.size - 1) { - val cycleLength = ChronoUnit.DAYS.between( - listPeriodDates[i], - listPeriodDates[i + 1] - ) - cycleLengths.add(cycleLength) + val averageCycleLength: Double? = averageCycleLength(); + if (averageCycleLength == null) { + null + } else { + listPeriodDates.last().plusDays(averageCycleLength.toLong()) } - // Calculate the average cycle length - val averageLength = cycleLengths.average() - - listPeriodDates.last().plusDays(averageLength.toLong()) }.also { Log.d("TAG", "Expected period date Basic: $it") } @@ -144,13 +140,13 @@ class CalculationsHelper( // Takes the X (periodHistory) latest period start dates and calculates the average cycle length // X comes from app_settings in the database - override fun averageCycleLength(): Double { + override fun averageCycleLength(): Double? { val periodDates = dbHelper.getLatestXPeriodStart(periodHistory) // Check if there are enough dates to calculate cycle lengths if (periodDates.size < 2) { // Not enough data to calculate cycle lengths - return 0.0 + return null } // List to store cycle lengths @@ -252,4 +248,39 @@ class CalculationsHelper( override fun cycleDay(date: LocalDate): Flow = dbHelper.dbWriteTrigger.map { getCycleDay(date) } -} \ No newline at end of file + + override fun calculateNextPeriodDates(): PersistentSet { + + var datesSet: PersistentSet = persistentSetOf() + + val firstNextPeriodDate: LocalDate? = if (lutealCalculation == 1) { + //go to advanced calculation using X latest ovulations (set by user in settings) + Log.d("TAG", "Advanced calculation") + advancedNextPeriod() + } else { + calculateNextPeriod() + } + + if (firstNextPeriodDate == null) { + return datesSet + } else { + datesSet = datesSet.add(firstNextPeriodDate) + } + + // Basic calculation for far-away periods + Log.d("TAG", "Basic calculation") + val listPeriodDates = dbHelper.getLatestXPeriodStart(periodHistory) + + if (!listPeriodDates.isEmpty()) { + // Calculate the cycle lengths between consecutive periods + val averageCycleLength: Double? = averageCycleLength(); + if (averageCycleLength != null) { + for (numberOfCycles in 2..14) { + val daysShift = averageCycleLength * numberOfCycles + datesSet = datesSet.add(listPeriodDates.last().plusDays(daysShift.toLong())) + } + } + } + return datesSet + } +} diff --git a/app/src/main/java/com/mensinator/app/business/ICalculationsHelper.kt b/app/src/main/java/com/mensinator/app/business/ICalculationsHelper.kt index bddaeffa..7afde0f8 100644 --- a/app/src/main/java/com/mensinator/app/business/ICalculationsHelper.kt +++ b/app/src/main/java/com/mensinator/app/business/ICalculationsHelper.kt @@ -2,6 +2,7 @@ package com.mensinator.app.business import kotlinx.coroutines.flow.Flow import java.time.LocalDate +import kotlinx.collections.immutable.PersistentSet /** * This helper provides methods to calculate menstrual cycle related data @@ -29,7 +30,7 @@ interface ICalculationsHelper { * X comes from app_settings in the database * @return The average cycle length as a double. */ - fun averageCycleLength(): Double + fun averageCycleLength(): Double? /** * Calculates the average period length using the latest period start dates. @@ -63,4 +64,6 @@ interface ICalculationsHelper { fun getCycleDay(date: LocalDate): Int? fun cycleDay(date: LocalDate): Flow + + fun calculateNextPeriodDates(): PersistentSet } diff --git a/app/src/main/java/com/mensinator/app/business/IPeriodPrediction.kt b/app/src/main/java/com/mensinator/app/business/IPeriodPrediction.kt index 3f06e854..b9ea348f 100644 --- a/app/src/main/java/com/mensinator/app/business/IPeriodPrediction.kt +++ b/app/src/main/java/com/mensinator/app/business/IPeriodPrediction.kt @@ -1,7 +1,9 @@ package com.mensinator.app.business import java.time.LocalDate +import kotlinx.collections.immutable.PersistentSet interface IPeriodPrediction { - fun getPredictedPeriodDate(): LocalDate? + fun getPredictedPeriodDates(): PersistentSet + } diff --git a/app/src/main/java/com/mensinator/app/business/OvulationPrediction.kt b/app/src/main/java/com/mensinator/app/business/OvulationPrediction.kt index f726e90e..ccff25de 100644 --- a/app/src/main/java/com/mensinator/app/business/OvulationPrediction.kt +++ b/app/src/main/java/com/mensinator/app/business/OvulationPrediction.kt @@ -12,7 +12,7 @@ class OvulationPrediction( override fun getPredictedOvulationDate(): LocalDate? { val periodCount = dbHelper.getPeriodCount() val ovulationCount = dbHelper.getOvulationCount() - val periodPredictionDate = periodPrediction.getPredictedPeriodDate() + val periodPredictionDate = periodPrediction.getPredictedPeriodDates().firstOrNull() val lastOvulationDate = dbHelper.getNewestOvulationDate() val firstDayOfNextMonth = LocalDate.now().withDayOfMonth(1).plusMonths(1) val previousFirstPeriodDate = dbHelper.getFirstPreviousPeriodDate(firstDayOfNextMonth) @@ -55,4 +55,4 @@ class OvulationPrediction( null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/mensinator/app/business/PeriodPrediction.kt b/app/src/main/java/com/mensinator/app/business/PeriodPrediction.kt index a8e437f1..39aa69aa 100644 --- a/app/src/main/java/com/mensinator/app/business/PeriodPrediction.kt +++ b/app/src/main/java/com/mensinator/app/business/PeriodPrediction.kt @@ -1,18 +1,20 @@ package com.mensinator.app.business import java.time.LocalDate +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf class PeriodPrediction( private val dbHelper: IPeriodDatabaseHelper, private val calcHelper: ICalculationsHelper, ) : IPeriodPrediction { - override fun getPredictedPeriodDate(): LocalDate? { + override fun getPredictedPeriodDates(): PersistentSet { val periodCount = dbHelper.getPeriodCount() if (periodCount < 2) { - return null + return persistentSetOf() } - return calcHelper.calculateNextPeriod() + return calcHelper.calculateNextPeriodDates() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/mensinator/app/business/notifications/NotificationScheduler.kt b/app/src/main/java/com/mensinator/app/business/notifications/NotificationScheduler.kt index bb78f807..2a8b5fc3 100644 --- a/app/src/main/java/com/mensinator/app/business/notifications/NotificationScheduler.kt +++ b/app/src/main/java/com/mensinator/app/business/notifications/NotificationScheduler.kt @@ -34,7 +34,7 @@ class NotificationScheduler( withContext(dispatcherProvider.IO) { val periodReminderDays = dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey)?.value?.toIntOrNull() ?: defaultReminderDays - val nextPeriodDate = periodPrediction.getPredictedPeriodDate() + val nextPeriodDate = periodPrediction.getPredictedPeriodDates().firstOrNull() val initPeriodKeyOrCustomMessage = dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) val periodMessageText = diff --git a/app/src/main/java/com/mensinator/app/calendar/CalendarScreen.kt b/app/src/main/java/com/mensinator/app/calendar/CalendarScreen.kt index aa973544..d9acb1b0 100644 --- a/app/src/main/java/com/mensinator/app/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/mensinator/app/calendar/CalendarScreen.kt @@ -357,7 +357,7 @@ fun Day( val dayColors = when { day.date in state.selectedDays -> settingColors[ColorSetting.SELECTION] day.date in state.periodDates.keys -> settingColors[ColorSetting.PERIOD] - state.periodPredictionDate?.isEqual(day.date) == true -> settingColors[ColorSetting.EXPECTED_PERIOD] + day.date in state.periodPredictionDates -> settingColors[ColorSetting.EXPECTED_PERIOD] day.date in state.ovulationDates -> settingColors[ColorSetting.OVULATION] state.ovulationPredictionDate?.isEqual(day.date) == true -> settingColors[ColorSetting.EXPECTED_OVULATION] else -> null diff --git a/app/src/main/java/com/mensinator/app/calendar/CalendarViewModel.kt b/app/src/main/java/com/mensinator/app/calendar/CalendarViewModel.kt index 03a0d59c..a11cc3cb 100644 --- a/app/src/main/java/com/mensinator/app/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/mensinator/app/calendar/CalendarViewModel.kt @@ -47,7 +47,7 @@ class CalendarViewModel( _viewState.update { it.copy( showCycleNumbers = showCycleNumbersSetting, - periodPredictionDate = periodPrediction.getPredictedPeriodDate(), + periodPredictionDates = periodPrediction.getPredictedPeriodDates(), ovulationPredictionDate = ovulationPrediction.getPredictedOvulationDate(), periodDates = dbHelper.getPeriodDatesForMonthNew( it.focusedYearMonth.year, @@ -181,7 +181,7 @@ class CalendarViewModel( val showCycleNumbers: Boolean = false, val focusedYearMonth: YearMonth = YearMonth.now(), - val periodPredictionDate: LocalDate? = null, + val periodPredictionDates: PersistentSet = persistentSetOf(), val ovulationPredictionDate: LocalDate? = null, val periodDates: PersistentMap = persistentMapOf(), val symptomDates: PersistentMap> = persistentMapOf(), diff --git a/app/src/main/java/com/mensinator/app/statistics/StatisticsViewModel.kt b/app/src/main/java/com/mensinator/app/statistics/StatisticsViewModel.kt index eb569af2..26033aa4 100644 --- a/app/src/main/java/com/mensinator/app/statistics/StatisticsViewModel.kt +++ b/app/src/main/java/com/mensinator/app/statistics/StatisticsViewModel.kt @@ -47,18 +47,21 @@ class StatisticsViewModel( _viewState.update { it.copy( trackedPeriods = periodDatabaseHelper.getPeriodCount().toString(), - averageCycleLength = formatDays(calcHelper.averageCycleLength().formatToOneDecimalPoint()), + averageCycleLength = formatDays(calcHelper.averageCycleLength()?.formatToOneDecimalPoint()), averagePeriodLength = formatDays(calcHelper.averagePeriodLength().formatToOneDecimalPoint()), averageLutealLength =formatDays(calcHelper.averageLutealLength().formatToOneDecimalPoint()), follicleGrowthDays = calcHelper.averageFollicalGrowthInDays().formatToOneDecimalPoint(), ovulationPredictionDate = ovulationPrediction.getPredictedOvulationDate()?.format(dateFormatter) ?: dash, - periodPredictionDate = periodPrediction.getPredictedPeriodDate()?.format(dateFormatter) ?: dash, + periodPredictionDate = periodPrediction.getPredictedPeriodDates().firstOrNull()?.format(dateFormatter) ?: dash, ovulationCount = periodDatabaseHelper.getOvulationCount().toString() ) } } - private fun formatDays(text: String): String { + private fun formatDays(text: String?): String? { + if (text == null) { + return null; + } val days = appContext.getString(R.string.days) return "$text $days" } diff --git a/app/src/test/java/com/mensinator/app/PeriodPredictionTest.kt b/app/src/test/java/com/mensinator/app/PeriodPredictionTest.kt index af2fd7f2..c18053a4 100644 --- a/app/src/test/java/com/mensinator/app/PeriodPredictionTest.kt +++ b/app/src/test/java/com/mensinator/app/PeriodPredictionTest.kt @@ -33,6 +33,6 @@ class PeriodPredictionTest { periodPrediction = PeriodPrediction(dbHelper, calcHelper) - assertEquals(null, periodPrediction.getPredictedPeriodDate()) + assertEquals(null, periodPrediction.getPredictedPeriodDates().firstOrNull()) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/mensinator/app/business/NotificationSchedulerTest.kt b/app/src/test/java/com/mensinator/app/business/NotificationSchedulerTest.kt index 3ed7a0f7..821668ce 100644 --- a/app/src/test/java/com/mensinator/app/business/NotificationSchedulerTest.kt +++ b/app/src/test/java/com/mensinator/app/business/NotificationSchedulerTest.kt @@ -14,6 +14,7 @@ import io.mockk.impl.annotations.RelaxedMockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.collections.immutable.persistentSetOf import org.junit.After import org.junit.Before import org.junit.Test @@ -76,6 +77,7 @@ class NotificationSchedulerTest { val reminderDays = 2 val today = LocalDate.now() val predictedDate = today.plusDays(5) + val predictedDates = persistentSetOf(predictedDate) val expectedNotificationDate = predictedDate.minusDays(reminderDays.toLong()) val customMessage = "Custom period message" val mappedMessage = "Your period is coming in 2 days" @@ -83,7 +85,7 @@ class NotificationSchedulerTest { coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns mockk { every { value } returns reminderDays.toString() } - every { periodPrediction.getPredictedPeriodDate() } returns predictedDate + every { periodPrediction.getPredictedPeriodDates() } returns predictedDates coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns customMessage every { ResourceMapper.getPeriodReminderMessage(customMessage, any()) @@ -104,12 +106,12 @@ class NotificationSchedulerTest { fun `do not schedule notification when reminder days is zero`() = runTest(testDispatcher) { // Given val reminderDays = 0 - val predictedDate = LocalDate.now().plusDays(5) + val predictedDates = persistentSetOf(LocalDate.now().plusDays(5)) coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns mockk { every { value } returns reminderDays.toString() } - every { periodPrediction.getPredictedPeriodDate() } returns predictedDate + every { periodPrediction.getPredictedPeriodDates() } returns predictedDates coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns "message" every { ResourceMapper.getPeriodReminderMessage(any(), any()) } returns "mapped message" @@ -126,11 +128,12 @@ class NotificationSchedulerTest { val reminderDays = 3 val today = LocalDate.now() val predictedDate = today.plusDays(2) // This makes the reminder date in the past + val predictedDates = persistentSetOf(predictedDate) coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns mockk { every { value } returns reminderDays.toString() } - every { periodPrediction.getPredictedPeriodDate() } returns predictedDate + every { periodPrediction.getPredictedPeriodDates() } returns predictedDates coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns "message" every { ResourceMapper.getPeriodReminderMessage(any(), any()) @@ -151,7 +154,7 @@ class NotificationSchedulerTest { coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns mockk { every { value } returns reminderDays.toString() } - every { periodPrediction.getPredictedPeriodDate() } returns null + every { periodPrediction.getPredictedPeriodDates() } returns persistentSetOf() coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns "message" every { ResourceMapper.getPeriodReminderMessage(any(), any()) } returns "mapped message" @@ -168,11 +171,12 @@ class NotificationSchedulerTest { val defaultReminderDays = 2 val today = LocalDate.now() val predictedDate = today.plusDays(5) + val predictedDates = persistentSetOf(predictedDate) val expectedNotificationDate = predictedDate.minusDays(defaultReminderDays.toLong()) val message = "message" coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns null - every { periodPrediction.getPredictedPeriodDate() } returns predictedDate + every { periodPrediction.getPredictedPeriodDates() } returns predictedDates coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns message every { ResourceMapper.getPeriodReminderMessage(any(), any()) } returns message @@ -191,13 +195,14 @@ class NotificationSchedulerTest { val defaultReminderDays = 2 val today = LocalDate.now() val predictedDate = today.plusDays(5) + val predictedDates = persistentSetOf(predictedDate) val expectedNotificationDate = predictedDate.minusDays(defaultReminderDays.toLong()) val message = "message" coEvery { dbHelper.getSettingByKey(IntSetting.REMINDER_DAYS.settingDbKey) } returns mockk { every { value } returns "not a number" } - every { periodPrediction.getPredictedPeriodDate() } returns predictedDate + every { periodPrediction.getPredictedPeriodDates() } returns predictedDates coEvery { dbHelper.getStringSettingByKey(StringSetting.PERIOD_NOTIFICATION_MESSAGE.settingDbKey) } returns message every { ResourceMapper.getPeriodReminderMessage(any(), any()) } returns message