Project period predictions across calendar ranges
All checks were successful
Build Org Backend / build (push) Successful in 15s

This commit is contained in:
2026-06-22 17:10:57 +01:00
parent 7bae39fe62
commit e4f175526f
3 changed files with 75 additions and 5 deletions

View File

@@ -93,7 +93,13 @@ public class PeriodCompanionService {
validateRange(from, to); validateRange(from, to);
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc(); List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
PeriodSettings settings = getOrCreateSettings(); PeriodSettings settings = getOrCreateSettings();
PredictionDTO prediction = latestPrediction(cycles, LocalDate.now(), settings); List<PredictionEstimate> projections = predictionService.project(
cycles,
LocalDate.now(),
from,
to,
settings.getAverageCycleLength(),
settings.getAveragePeriodLength());
Map<LocalDate, PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to) Map<LocalDate, PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to)
.stream() .stream()
.collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity())); .collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity()));
@@ -106,9 +112,9 @@ public class PeriodCompanionService {
days.add(new CalendarDayDTO( days.add(new CalendarDayDTO(
date, date,
periodDay, periodDay,
isPredictedPeriodDay(prediction, date), projections.stream().anyMatch(projection -> isPredictedPeriodDay(projection, currentDate)),
!date.isBefore(prediction.fertileWindowStart()) && !date.isAfter(prediction.fertileWindowEnd()), projections.stream().anyMatch(projection -> isFertileWindowDay(projection, currentDate)),
date.equals(prediction.predictedOvulationDate()), projections.stream().anyMatch(projection -> currentDate.equals(projection.predictedOvulationDate())),
log == null ? List.of() : log.getSymptoms().stream().map(PeriodSymptom::getName).sorted().toList(), log == null ? List.of() : log.getSymptoms().stream().map(PeriodSymptom::getName).sorted().toList(),
log == null ? null : toDailyLogDto(log))); log == null ? null : toDailyLogDto(log)));
} }
@@ -291,11 +297,15 @@ public class PeriodCompanionService {
return !date.isBefore(cycle.getStartDate()) && date.isBefore(cycle.getStartDate().plusDays(periodLength)); 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()) return !date.isBefore(prediction.predictedPeriodDate())
&& date.isBefore(prediction.predictedPeriodDate().plusDays(prediction.averagePeriodLength())); && 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<String> contextualSupportMessages(PredictionDTO prediction, LocalDate today) { private List<String> contextualSupportMessages(PredictionDTO prediction, LocalDate today) {
long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate()); long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate());
long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate()); long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate());

View File

@@ -9,6 +9,7 @@ import org.zaine.app.model.PeriodPredictionSnapshot;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -45,6 +46,36 @@ public class PeriodPredictionService {
averagePeriodLength); averagePeriodLength);
} }
public List<PredictionEstimate> project(
List<PeriodCycle> cycles,
LocalDate today,
LocalDate from,
LocalDate to,
int fallbackCycleLength,
int fallbackPeriodLength) {
PredictionEstimate first = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength);
List<PredictionEstimate> 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<PeriodCycle> cycles, LocalDate today) { public CurrentCycleDTO currentCycle(List<PeriodCycle> cycles, LocalDate today) {
return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH); return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
} }
@@ -125,6 +156,16 @@ public class PeriodPredictionService {
return (int) Math.round(average); 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( public record PredictionEstimate(
LocalDate predictedPeriodDate, LocalDate predictedPeriodDate,
LocalDate predictedOvulationDate, LocalDate predictedOvulationDate,

View File

@@ -52,6 +52,25 @@ public class PeriodPredictionServiceTest {
assertEquals(LocalDate.of(2026, 6, 15), estimate.predictedOvulationDate()); assertEquals(LocalDate.of(2026, 6, 15), estimate.predictedOvulationDate());
} }
@Test
public void projectReturnsRepeatedPredictionsAcrossLongDateRange() {
LocalDate today = LocalDate.of(2026, 6, 22);
List<PredictionEstimate> 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 @Test
public void currentCycleReturnsMenstrualPhaseDuringPeriodLength() { public void currentCycleReturnsMenstrualPhaseDuringPeriodLength() {
PeriodCycle cycle = cycle(LocalDate.of(2026, 6, 20), LocalDate.of(2026, 6, 24), 28, 5); PeriodCycle cycle = cycle(LocalDate.of(2026, 6, 20), LocalDate.of(2026, 6, 24), 28, 5);