From e4f175526fca48f561bde93c21c5f267ce2e36de Mon Sep 17 00:00:00 2001 From: Zaine Date: Mon, 22 Jun 2026 17:10:57 +0100 Subject: [PATCH] Project period predictions across calendar ranges --- .../app/service/PeriodCompanionService.java | 20 ++++++--- .../app/service/PeriodPredictionService.java | 41 +++++++++++++++++++ .../service/PeriodPredictionServiceTest.java | 19 +++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/zaine/app/service/PeriodCompanionService.java b/src/main/java/org/zaine/app/service/PeriodCompanionService.java index 4147775..5dd3e0e 100644 --- a/src/main/java/org/zaine/app/service/PeriodCompanionService.java +++ b/src/main/java/org/zaine/app/service/PeriodCompanionService.java @@ -93,7 +93,13 @@ public class PeriodCompanionService { validateRange(from, to); List cycles = cycleRepository.findAllByOrderByStartDateAsc(); PeriodSettings settings = getOrCreateSettings(); - PredictionDTO prediction = latestPrediction(cycles, LocalDate.now(), settings); + List projections = predictionService.project( + cycles, + LocalDate.now(), + from, + to, + settings.getAverageCycleLength(), + settings.getAveragePeriodLength()); Map logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to) .stream() .collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity())); @@ -106,9 +112,9 @@ public class PeriodCompanionService { days.add(new CalendarDayDTO( date, periodDay, - isPredictedPeriodDay(prediction, date), - !date.isBefore(prediction.fertileWindowStart()) && !date.isAfter(prediction.fertileWindowEnd()), - date.equals(prediction.predictedOvulationDate()), + projections.stream().anyMatch(projection -> isPredictedPeriodDay(projection, currentDate)), + projections.stream().anyMatch(projection -> isFertileWindowDay(projection, currentDate)), + projections.stream().anyMatch(projection -> currentDate.equals(projection.predictedOvulationDate())), log == null ? List.of() : log.getSymptoms().stream().map(PeriodSymptom::getName).sorted().toList(), log == null ? null : toDailyLogDto(log))); } @@ -291,11 +297,15 @@ public class PeriodCompanionService { return !date.isBefore(cycle.getStartDate()) && date.isBefore(cycle.getStartDate().plusDays(periodLength)); } - private boolean isPredictedPeriodDay(PredictionDTO prediction, LocalDate date) { + private boolean isPredictedPeriodDay(PredictionEstimate prediction, LocalDate date) { return !date.isBefore(prediction.predictedPeriodDate()) && date.isBefore(prediction.predictedPeriodDate().plusDays(prediction.averagePeriodLength())); } + private boolean isFertileWindowDay(PredictionEstimate prediction, LocalDate date) { + return !date.isBefore(prediction.fertileWindowStart()) && !date.isAfter(prediction.fertileWindowEnd()); + } + private List contextualSupportMessages(PredictionDTO prediction, LocalDate today) { long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate()); long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate()); diff --git a/src/main/java/org/zaine/app/service/PeriodPredictionService.java b/src/main/java/org/zaine/app/service/PeriodPredictionService.java index db06cf7..7b374e5 100644 --- a/src/main/java/org/zaine/app/service/PeriodPredictionService.java +++ b/src/main/java/org/zaine/app/service/PeriodPredictionService.java @@ -9,6 +9,7 @@ import org.zaine.app.model.PeriodPredictionSnapshot; import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -45,6 +46,36 @@ public class PeriodPredictionService { averagePeriodLength); } + public List project( + List cycles, + LocalDate today, + LocalDate from, + LocalDate to, + int fallbackCycleLength, + int fallbackPeriodLength) { + PredictionEstimate first = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength); + List projections = new ArrayList<>(); + LocalDate predictedPeriodDate = first.predictedPeriodDate(); + + while (!predictedPeriodDate.minusDays(19).isAfter(to)) { + LocalDate predictedOvulationDate = predictedPeriodDate.minusDays(14); + PredictionEstimate projection = new PredictionEstimate( + predictedPeriodDate, + predictedOvulationDate, + predictedOvulationDate.minusDays(5), + predictedOvulationDate.plusDays(1), + first.averageCycleLength(), + first.averagePeriodLength()); + + if (intersectsRange(projection, from, to)) { + projections.add(projection); + } + predictedPeriodDate = predictedPeriodDate.plusDays(first.averageCycleLength()); + } + + return projections; + } + public CurrentCycleDTO currentCycle(List cycles, LocalDate today) { return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH); } @@ -125,6 +156,16 @@ public class PeriodPredictionService { return (int) Math.round(average); } + private boolean intersectsRange(PredictionEstimate projection, LocalDate from, LocalDate to) { + LocalDate periodEnd = projection.predictedPeriodDate().plusDays(projection.averagePeriodLength() - 1L); + return rangesOverlap(projection.predictedPeriodDate(), periodEnd, from, to) + || rangesOverlap(projection.fertileWindowStart(), projection.fertileWindowEnd(), from, to); + } + + private boolean rangesOverlap(LocalDate firstStart, LocalDate firstEnd, LocalDate secondStart, LocalDate secondEnd) { + return !firstStart.isAfter(secondEnd) && !secondStart.isAfter(firstEnd); + } + public record PredictionEstimate( LocalDate predictedPeriodDate, LocalDate predictedOvulationDate, diff --git a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java index d96a406..5a4413a 100644 --- a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java +++ b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java @@ -52,6 +52,25 @@ public class PeriodPredictionServiceTest { assertEquals(LocalDate.of(2026, 6, 15), estimate.predictedOvulationDate()); } + @Test + public void projectReturnsRepeatedPredictionsAcrossLongDateRange() { + LocalDate today = LocalDate.of(2026, 6, 22); + + List projections = service.project( + List.of(), + today, + LocalDate.of(2026, 7, 1), + LocalDate.of(2026, 9, 30), + 28, + 5); + + assertEquals(4, projections.size()); + assertEquals(LocalDate.of(2026, 7, 20), projections.get(0).predictedPeriodDate()); + assertEquals(LocalDate.of(2026, 8, 17), projections.get(1).predictedPeriodDate()); + assertEquals(LocalDate.of(2026, 9, 14), projections.get(2).predictedPeriodDate()); + assertEquals(LocalDate.of(2026, 10, 12), projections.get(3).predictedPeriodDate()); + } + @Test public void currentCycleReturnsMenstrualPhaseDuringPeriodLength() { PeriodCycle cycle = cycle(LocalDate.of(2026, 6, 20), LocalDate.of(2026, 6, 24), 28, 5);