Add configurable period prediction settings
All checks were successful
Build Org Backend / build (push) Successful in 12s
All checks were successful
Build Org Backend / build (push) Successful in 12s
This commit is contained in:
@@ -21,6 +21,8 @@ import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO;
|
|||||||
import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO;
|
||||||
|
import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO;
|
||||||
|
import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO;
|
||||||
@@ -115,6 +117,18 @@ public class PeriodCompanionController {
|
|||||||
return periodCompanionService.getInsights();
|
return periodCompanionService.getInsights();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get period companion settings")
|
||||||
|
@GetMapping("/settings")
|
||||||
|
public SettingsDTO getSettings() {
|
||||||
|
return periodCompanionService.getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Update period companion settings")
|
||||||
|
@PostMapping("/settings")
|
||||||
|
public ResponseEntity<SettingsDTO> updateSettings(@RequestBody SettingsRequest request) {
|
||||||
|
return ResponseEntity.ok(periodCompanionService.updateSettings(request));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "Get recent support messages")
|
@Operation(summary = "Get recent support messages")
|
||||||
@GetMapping("/support-messages")
|
@GetMapping("/support-messages")
|
||||||
public List<SupportMessageDTO> getSupportMessages() {
|
public List<SupportMessageDTO> getSupportMessages() {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public final class PeriodCompanionDTO {
|
|||||||
CurrentCycleDTO currentCycle,
|
CurrentCycleDTO currentCycle,
|
||||||
PredictionDTO prediction,
|
PredictionDTO prediction,
|
||||||
DailyLogDTO today,
|
DailyLogDTO today,
|
||||||
|
SettingsDTO settings,
|
||||||
List<String> supportMessages,
|
List<String> supportMessages,
|
||||||
List<SupportMessageDTO> recentPartnerMessages) {}
|
List<SupportMessageDTO> recentPartnerMessages) {}
|
||||||
|
|
||||||
@@ -86,4 +87,6 @@ public final class PeriodCompanionDTO {
|
|||||||
public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {}
|
public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {}
|
||||||
public record SupportMessageRequest(String authorName, String message) {}
|
public record SupportMessageRequest(String authorName, String message) {}
|
||||||
public record CreateSymptomRequest(String name) {}
|
public record CreateSymptomRequest(String name) {}
|
||||||
|
public record SettingsDTO(int averageCycleLength, int averagePeriodLength, OffsetDateTime updatedAt) {}
|
||||||
|
public record SettingsRequest(Integer averageCycleLength, Integer averagePeriodLength) {}
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/main/java/org/zaine/app/model/PeriodSettings.java
Normal file
46
src/main/java/org/zaine/app/model/PeriodSettings.java
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "pc_settings")
|
||||||
|
public class PeriodSettings {
|
||||||
|
public static final Short SINGLETON_ID = 1;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Short id = SINGLETON_ID;
|
||||||
|
|
||||||
|
@Column(name = "average_cycle_length", nullable = false)
|
||||||
|
private Integer averageCycleLength = 28;
|
||||||
|
|
||||||
|
@Column(name = "average_period_length", nullable = false)
|
||||||
|
private Integer averagePeriodLength = 5;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onInsert() {
|
||||||
|
this.updatedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Short getId() { return id; }
|
||||||
|
public void setId(Short id) { this.id = id; }
|
||||||
|
public Integer getAverageCycleLength() { return averageCycleLength; }
|
||||||
|
public void setAverageCycleLength(Integer averageCycleLength) { this.averageCycleLength = averageCycleLength; }
|
||||||
|
public Integer getAveragePeriodLength() { return averagePeriodLength; }
|
||||||
|
public void setAveragePeriodLength(Integer averagePeriodLength) { this.averagePeriodLength = averagePeriodLength; }
|
||||||
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.zaine.app.model.PeriodSettings;
|
||||||
|
|
||||||
|
public interface PeriodSettingsRepository extends JpaRepository<PeriodSettings, Short> {
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO;
|
|||||||
import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.PeriodEntryRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO;
|
||||||
|
import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO;
|
||||||
|
import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest;
|
import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest;
|
||||||
import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO;
|
import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO;
|
||||||
@@ -23,11 +25,13 @@ import org.zaine.app.dto.PeriodCompanionDTO.SymptomTrendDTO;
|
|||||||
import org.zaine.app.model.PeriodCycle;
|
import org.zaine.app.model.PeriodCycle;
|
||||||
import org.zaine.app.model.PeriodDailyLog;
|
import org.zaine.app.model.PeriodDailyLog;
|
||||||
import org.zaine.app.model.PeriodPredictionSnapshot;
|
import org.zaine.app.model.PeriodPredictionSnapshot;
|
||||||
|
import org.zaine.app.model.PeriodSettings;
|
||||||
import org.zaine.app.model.PeriodSupportMessage;
|
import org.zaine.app.model.PeriodSupportMessage;
|
||||||
import org.zaine.app.model.PeriodSymptom;
|
import org.zaine.app.model.PeriodSymptom;
|
||||||
import org.zaine.app.repositories.PeriodCycleRepository;
|
import org.zaine.app.repositories.PeriodCycleRepository;
|
||||||
import org.zaine.app.repositories.PeriodDailyLogRepository;
|
import org.zaine.app.repositories.PeriodDailyLogRepository;
|
||||||
import org.zaine.app.repositories.PeriodPredictionSnapshotRepository;
|
import org.zaine.app.repositories.PeriodPredictionSnapshotRepository;
|
||||||
|
import org.zaine.app.repositories.PeriodSettingsRepository;
|
||||||
import org.zaine.app.repositories.PeriodSupportMessageRepository;
|
import org.zaine.app.repositories.PeriodSupportMessageRepository;
|
||||||
import org.zaine.app.repositories.PeriodSymptomRepository;
|
import org.zaine.app.repositories.PeriodSymptomRepository;
|
||||||
import org.zaine.app.service.PeriodPredictionService.PredictionEstimate;
|
import org.zaine.app.service.PeriodPredictionService.PredictionEstimate;
|
||||||
@@ -49,6 +53,7 @@ public class PeriodCompanionService {
|
|||||||
private final PeriodDailyLogRepository dailyLogRepository;
|
private final PeriodDailyLogRepository dailyLogRepository;
|
||||||
private final PeriodSymptomRepository symptomRepository;
|
private final PeriodSymptomRepository symptomRepository;
|
||||||
private final PeriodPredictionSnapshotRepository predictionRepository;
|
private final PeriodPredictionSnapshotRepository predictionRepository;
|
||||||
|
private final PeriodSettingsRepository settingsRepository;
|
||||||
private final PeriodSupportMessageRepository supportMessageRepository;
|
private final PeriodSupportMessageRepository supportMessageRepository;
|
||||||
private final PeriodPredictionService predictionService;
|
private final PeriodPredictionService predictionService;
|
||||||
|
|
||||||
@@ -57,12 +62,14 @@ public class PeriodCompanionService {
|
|||||||
PeriodDailyLogRepository dailyLogRepository,
|
PeriodDailyLogRepository dailyLogRepository,
|
||||||
PeriodSymptomRepository symptomRepository,
|
PeriodSymptomRepository symptomRepository,
|
||||||
PeriodPredictionSnapshotRepository predictionRepository,
|
PeriodPredictionSnapshotRepository predictionRepository,
|
||||||
|
PeriodSettingsRepository settingsRepository,
|
||||||
PeriodSupportMessageRepository supportMessageRepository,
|
PeriodSupportMessageRepository supportMessageRepository,
|
||||||
PeriodPredictionService predictionService) {
|
PeriodPredictionService predictionService) {
|
||||||
this.cycleRepository = cycleRepository;
|
this.cycleRepository = cycleRepository;
|
||||||
this.dailyLogRepository = dailyLogRepository;
|
this.dailyLogRepository = dailyLogRepository;
|
||||||
this.symptomRepository = symptomRepository;
|
this.symptomRepository = symptomRepository;
|
||||||
this.predictionRepository = predictionRepository;
|
this.predictionRepository = predictionRepository;
|
||||||
|
this.settingsRepository = settingsRepository;
|
||||||
this.supportMessageRepository = supportMessageRepository;
|
this.supportMessageRepository = supportMessageRepository;
|
||||||
this.predictionService = predictionService;
|
this.predictionService = predictionService;
|
||||||
}
|
}
|
||||||
@@ -70,12 +77,14 @@ public class PeriodCompanionService {
|
|||||||
public DashboardDTO getDashboard() {
|
public DashboardDTO getDashboard() {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
||||||
PredictionDTO prediction = latestPrediction(cycles, today);
|
PeriodSettings settings = getOrCreateSettings();
|
||||||
|
PredictionDTO prediction = latestPrediction(cycles, today, settings);
|
||||||
DailyLogDTO todayLog = dailyLogRepository.findByDate(today).map(this::toDailyLogDto).orElse(null);
|
DailyLogDTO todayLog = dailyLogRepository.findByDate(today).map(this::toDailyLogDto).orElse(null);
|
||||||
return new DashboardDTO(
|
return new DashboardDTO(
|
||||||
predictionService.currentCycle(cycles, today),
|
predictionService.currentCycle(cycles, today, settings.getAverageCycleLength(), settings.getAveragePeriodLength()),
|
||||||
prediction,
|
prediction,
|
||||||
todayLog,
|
todayLog,
|
||||||
|
toSettingsDto(settings),
|
||||||
contextualSupportMessages(prediction, today),
|
contextualSupportMessages(prediction, today),
|
||||||
getSupportMessages());
|
getSupportMessages());
|
||||||
}
|
}
|
||||||
@@ -83,7 +92,8 @@ public class PeriodCompanionService {
|
|||||||
public List<CalendarDayDTO> getCalendar(LocalDate from, LocalDate to) {
|
public List<CalendarDayDTO> getCalendar(LocalDate from, LocalDate to) {
|
||||||
validateRange(from, to);
|
validateRange(from, to);
|
||||||
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
||||||
PredictionDTO prediction = latestPrediction(cycles, LocalDate.now());
|
PeriodSettings settings = getOrCreateSettings();
|
||||||
|
PredictionDTO prediction = latestPrediction(cycles, LocalDate.now(), settings);
|
||||||
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()));
|
||||||
@@ -177,7 +187,12 @@ public class PeriodCompanionService {
|
|||||||
|
|
||||||
public PredictionDTO generatePrediction() {
|
public PredictionDTO generatePrediction() {
|
||||||
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
||||||
PredictionEstimate estimate = predictionService.estimate(cycles, LocalDate.now());
|
PeriodSettings settings = getOrCreateSettings();
|
||||||
|
PredictionEstimate estimate = predictionService.estimate(
|
||||||
|
cycles,
|
||||||
|
LocalDate.now(),
|
||||||
|
settings.getAverageCycleLength(),
|
||||||
|
settings.getAveragePeriodLength());
|
||||||
PeriodPredictionSnapshot snapshot = new PeriodPredictionSnapshot();
|
PeriodPredictionSnapshot snapshot = new PeriodPredictionSnapshot();
|
||||||
snapshot.setPredictedPeriodDate(estimate.predictedPeriodDate());
|
snapshot.setPredictedPeriodDate(estimate.predictedPeriodDate());
|
||||||
snapshot.setPredictedOvulationDate(estimate.predictedOvulationDate());
|
snapshot.setPredictedOvulationDate(estimate.predictedOvulationDate());
|
||||||
@@ -209,6 +224,26 @@ public class PeriodCompanionService {
|
|||||||
return new InsightsDTO(cycleTrends, symptomTrends, moodTrends);
|
return new InsightsDTO(cycleTrends, symptomTrends, moodTrends);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SettingsDTO getSettings() {
|
||||||
|
return toSettingsDto(getOrCreateSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsDTO updateSettings(SettingsRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "settings payload is required");
|
||||||
|
}
|
||||||
|
PeriodSettings settings = getOrCreateSettings();
|
||||||
|
if (request.averageCycleLength() != null) {
|
||||||
|
validateIntegerRange(request.averageCycleLength(), 15, 60, "averageCycleLength");
|
||||||
|
settings.setAverageCycleLength(request.averageCycleLength());
|
||||||
|
}
|
||||||
|
if (request.averagePeriodLength() != null) {
|
||||||
|
validateIntegerRange(request.averagePeriodLength(), 1, 15, "averagePeriodLength");
|
||||||
|
settings.setAveragePeriodLength(request.averagePeriodLength());
|
||||||
|
}
|
||||||
|
return toSettingsDto(settingsRepository.save(settings));
|
||||||
|
}
|
||||||
|
|
||||||
public List<SupportMessageDTO> getSupportMessages() {
|
public List<SupportMessageDTO> getSupportMessages() {
|
||||||
return supportMessageRepository.findTop20ByOrderByCreatedAtDesc().stream().map(this::toSupportMessageDto).toList();
|
return supportMessageRepository.findTop20ByOrderByCreatedAtDesc().stream().map(this::toSupportMessageDto).toList();
|
||||||
}
|
}
|
||||||
@@ -220,11 +255,19 @@ public class PeriodCompanionService {
|
|||||||
return toSupportMessageDto(supportMessageRepository.save(message));
|
return toSupportMessageDto(supportMessageRepository.save(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
private PredictionDTO latestPrediction(List<PeriodCycle> cycles, LocalDate today) {
|
private PredictionDTO latestPrediction(List<PeriodCycle> cycles, LocalDate today, PeriodSettings settings) {
|
||||||
PredictionEstimate estimate = predictionService.estimate(cycles, today);
|
PredictionEstimate estimate = predictionService.estimate(
|
||||||
|
cycles,
|
||||||
|
today,
|
||||||
|
settings.getAverageCycleLength(),
|
||||||
|
settings.getAveragePeriodLength());
|
||||||
return predictionRepository.findFirstByOrderByGeneratedAtDesc()
|
return predictionRepository.findFirstByOrderByGeneratedAtDesc()
|
||||||
.map(snapshot -> predictionService.toPredictionDto(snapshot, estimate))
|
.map(snapshot -> predictionService.toPredictionDto(snapshot, estimate))
|
||||||
.orElseGet(this::generatePrediction);
|
.orElseGet(() -> generatePrediction());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeriodSettings getOrCreateSettings() {
|
||||||
|
return settingsRepository.findById(PeriodSettings.SINGLETON_ID).orElseGet(() -> settingsRepository.save(new PeriodSettings()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setDerivedCycleValues(PeriodCycle cycle) {
|
private void setDerivedCycleValues(PeriodCycle cycle) {
|
||||||
@@ -313,6 +356,10 @@ public class PeriodCompanionService {
|
|||||||
return new SupportMessageDTO(message.getId(), message.getAuthorName(), message.getMessage(), message.getCreatedAt());
|
return new SupportMessageDTO(message.getId(), message.getAuthorName(), message.getMessage(), message.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SettingsDTO toSettingsDto(PeriodSettings settings) {
|
||||||
|
return new SettingsDTO(settings.getAverageCycleLength(), settings.getAveragePeriodLength(), settings.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
private void validateRange(LocalDate from, LocalDate to) {
|
private void validateRange(LocalDate from, LocalDate to) {
|
||||||
requiredDate(from, "from");
|
requiredDate(from, "from");
|
||||||
requiredDate(to, "to");
|
requiredDate(to, "to");
|
||||||
@@ -348,6 +395,12 @@ public class PeriodCompanionService {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateIntegerRange(Integer value, int min, int max, String field) {
|
||||||
|
if (value < min || value > max) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be between " + min + " and " + max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String blankToNull(String value) {
|
private String blankToNull(String value) {
|
||||||
return value == null || value.isBlank() ? null : value.trim();
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import java.util.Optional;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PeriodPredictionService {
|
public class PeriodPredictionService {
|
||||||
private static final int DEFAULT_CYCLE_LENGTH = 28;
|
public static final int DEFAULT_CYCLE_LENGTH = 28;
|
||||||
private static final int DEFAULT_PERIOD_LENGTH = 5;
|
public static final int DEFAULT_PERIOD_LENGTH = 5;
|
||||||
|
|
||||||
public PredictionEstimate estimate(List<PeriodCycle> cycles, LocalDate today) {
|
public PredictionEstimate estimate(List<PeriodCycle> cycles, LocalDate today) {
|
||||||
int averageCycleLength = averageCycleLength(cycles);
|
return estimate(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
|
||||||
int averagePeriodLength = averagePeriodLength(cycles);
|
}
|
||||||
|
|
||||||
|
public PredictionEstimate estimate(List<PeriodCycle> cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) {
|
||||||
|
int averageCycleLength = averageCycleLength(cycles, fallbackCycleLength);
|
||||||
|
int averagePeriodLength = averagePeriodLength(cycles, fallbackPeriodLength);
|
||||||
LocalDate anchor = cycles.stream()
|
LocalDate anchor = cycles.stream()
|
||||||
.map(PeriodCycle::getStartDate)
|
.map(PeriodCycle::getStartDate)
|
||||||
.max(Comparator.naturalOrder())
|
.max(Comparator.naturalOrder())
|
||||||
@@ -42,7 +46,11 @@ public class PeriodPredictionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CurrentCycleDTO currentCycle(List<PeriodCycle> cycles, LocalDate today) {
|
public CurrentCycleDTO currentCycle(List<PeriodCycle> cycles, LocalDate today) {
|
||||||
PredictionEstimate estimate = estimate(cycles, today);
|
return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CurrentCycleDTO currentCycle(List<PeriodCycle> cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) {
|
||||||
|
PredictionEstimate estimate = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength);
|
||||||
Optional<PeriodCycle> current = cycles.stream()
|
Optional<PeriodCycle> current = cycles.stream()
|
||||||
.filter(cycle -> !cycle.getStartDate().isAfter(today))
|
.filter(cycle -> !cycle.getStartDate().isAfter(today))
|
||||||
.max(Comparator.comparing(PeriodCycle::getStartDate));
|
.max(Comparator.comparing(PeriodCycle::getStartDate));
|
||||||
@@ -93,20 +101,20 @@ public class PeriodPredictionService {
|
|||||||
cycle.getUpdatedAt());
|
cycle.getUpdatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private int averageCycleLength(List<PeriodCycle> cycles) {
|
private int averageCycleLength(List<PeriodCycle> cycles, int fallbackCycleLength) {
|
||||||
List<Integer> values = cycles.stream()
|
List<Integer> values = cycles.stream()
|
||||||
.map(PeriodCycle::getCycleLength)
|
.map(PeriodCycle::getCycleLength)
|
||||||
.filter(length -> length != null && length >= 15 && length <= 60)
|
.filter(length -> length != null && length >= 15 && length <= 60)
|
||||||
.toList();
|
.toList();
|
||||||
return roundedAverage(values, DEFAULT_CYCLE_LENGTH);
|
return roundedAverage(values, fallbackCycleLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int averagePeriodLength(List<PeriodCycle> cycles) {
|
private int averagePeriodLength(List<PeriodCycle> cycles, int fallbackPeriodLength) {
|
||||||
List<Integer> values = cycles.stream()
|
List<Integer> values = cycles.stream()
|
||||||
.map(PeriodCycle::getPeriodLength)
|
.map(PeriodCycle::getPeriodLength)
|
||||||
.filter(length -> length != null && length >= 1 && length <= 15)
|
.filter(length -> length != null && length >= 1 && length <= 15)
|
||||||
.toList();
|
.toList();
|
||||||
return roundedAverage(values, DEFAULT_PERIOD_LENGTH);
|
return roundedAverage(values, fallbackPeriodLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int roundedAverage(List<Integer> values, int fallback) {
|
private int roundedAverage(List<Integer> values, int fallback) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pc_settings (
|
||||||
|
id SMALLINT PRIMARY KEY DEFAULT 1,
|
||||||
|
average_cycle_length INTEGER NOT NULL DEFAULT 28,
|
||||||
|
average_period_length INTEGER NOT NULL DEFAULT 5,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_pc_settings_singleton CHECK (id = 1),
|
||||||
|
CONSTRAINT chk_pc_settings_cycle_length CHECK (average_cycle_length BETWEEN 15 AND 60),
|
||||||
|
CONSTRAINT chk_pc_settings_period_length CHECK (average_period_length BETWEEN 1 AND 15)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO pc_settings (id, average_cycle_length, average_period_length)
|
||||||
|
VALUES (1, 28, 5)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -28,6 +28,17 @@ public class PeriodPredictionServiceTest {
|
|||||||
assertEquals(5, estimate.averagePeriodLength());
|
assertEquals(5, estimate.averagePeriodLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void estimateUsesConfiguredFallbackLengthsWhenNoHistoryExists() {
|
||||||
|
LocalDate today = LocalDate.of(2026, 6, 22);
|
||||||
|
|
||||||
|
PredictionEstimate estimate = service.estimate(List.of(), today, 32, 6);
|
||||||
|
|
||||||
|
assertEquals(LocalDate.of(2026, 7, 24), estimate.predictedPeriodDate());
|
||||||
|
assertEquals(32, estimate.averageCycleLength());
|
||||||
|
assertEquals(6, estimate.averagePeriodLength());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void estimateUsesHistoricalAveragesForFuturePrediction() {
|
public void estimateUsesHistoricalAveragesForFuturePrediction() {
|
||||||
LocalDate today = LocalDate.of(2026, 6, 22);
|
LocalDate today = LocalDate.of(2026, 6, 22);
|
||||||
|
|||||||
Reference in New Issue
Block a user