Project period predictions across calendar ranges
All checks were successful
Build Org Backend / build (push) Successful in 15s
All checks were successful
Build Org Backend / build (push) Successful in 15s
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user