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.PeriodEntryRequest;
|
||||
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.SupportMessageRequest;
|
||||
import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO;
|
||||
@@ -115,6 +117,18 @@ public class PeriodCompanionController {
|
||||
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")
|
||||
@GetMapping("/support-messages")
|
||||
public List<SupportMessageDTO> getSupportMessages() {
|
||||
|
||||
@@ -62,6 +62,7 @@ public final class PeriodCompanionDTO {
|
||||
CurrentCycleDTO currentCycle,
|
||||
PredictionDTO prediction,
|
||||
DailyLogDTO today,
|
||||
SettingsDTO settings,
|
||||
List<String> supportMessages,
|
||||
List<SupportMessageDTO> recentPartnerMessages) {}
|
||||
|
||||
@@ -86,4 +87,6 @@ public final class PeriodCompanionDTO {
|
||||
public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {}
|
||||
public record SupportMessageRequest(String authorName, String message) {}
|
||||
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.PeriodEntryRequest;
|
||||
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.SupportMessageRequest;
|
||||
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.PeriodDailyLog;
|
||||
import org.zaine.app.model.PeriodPredictionSnapshot;
|
||||
import org.zaine.app.model.PeriodSettings;
|
||||
import org.zaine.app.model.PeriodSupportMessage;
|
||||
import org.zaine.app.model.PeriodSymptom;
|
||||
import org.zaine.app.repositories.PeriodCycleRepository;
|
||||
import org.zaine.app.repositories.PeriodDailyLogRepository;
|
||||
import org.zaine.app.repositories.PeriodPredictionSnapshotRepository;
|
||||
import org.zaine.app.repositories.PeriodSettingsRepository;
|
||||
import org.zaine.app.repositories.PeriodSupportMessageRepository;
|
||||
import org.zaine.app.repositories.PeriodSymptomRepository;
|
||||
import org.zaine.app.service.PeriodPredictionService.PredictionEstimate;
|
||||
@@ -49,6 +53,7 @@ public class PeriodCompanionService {
|
||||
private final PeriodDailyLogRepository dailyLogRepository;
|
||||
private final PeriodSymptomRepository symptomRepository;
|
||||
private final PeriodPredictionSnapshotRepository predictionRepository;
|
||||
private final PeriodSettingsRepository settingsRepository;
|
||||
private final PeriodSupportMessageRepository supportMessageRepository;
|
||||
private final PeriodPredictionService predictionService;
|
||||
|
||||
@@ -57,12 +62,14 @@ public class PeriodCompanionService {
|
||||
PeriodDailyLogRepository dailyLogRepository,
|
||||
PeriodSymptomRepository symptomRepository,
|
||||
PeriodPredictionSnapshotRepository predictionRepository,
|
||||
PeriodSettingsRepository settingsRepository,
|
||||
PeriodSupportMessageRepository supportMessageRepository,
|
||||
PeriodPredictionService predictionService) {
|
||||
this.cycleRepository = cycleRepository;
|
||||
this.dailyLogRepository = dailyLogRepository;
|
||||
this.symptomRepository = symptomRepository;
|
||||
this.predictionRepository = predictionRepository;
|
||||
this.settingsRepository = settingsRepository;
|
||||
this.supportMessageRepository = supportMessageRepository;
|
||||
this.predictionService = predictionService;
|
||||
}
|
||||
@@ -70,12 +77,14 @@ public class PeriodCompanionService {
|
||||
public DashboardDTO getDashboard() {
|
||||
LocalDate today = LocalDate.now();
|
||||
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);
|
||||
return new DashboardDTO(
|
||||
predictionService.currentCycle(cycles, today),
|
||||
predictionService.currentCycle(cycles, today, settings.getAverageCycleLength(), settings.getAveragePeriodLength()),
|
||||
prediction,
|
||||
todayLog,
|
||||
toSettingsDto(settings),
|
||||
contextualSupportMessages(prediction, today),
|
||||
getSupportMessages());
|
||||
}
|
||||
@@ -83,7 +92,8 @@ public class PeriodCompanionService {
|
||||
public List<CalendarDayDTO> getCalendar(LocalDate from, LocalDate to) {
|
||||
validateRange(from, to);
|
||||
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)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity()));
|
||||
@@ -177,7 +187,12 @@ public class PeriodCompanionService {
|
||||
|
||||
public PredictionDTO generatePrediction() {
|
||||
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();
|
||||
snapshot.setPredictedPeriodDate(estimate.predictedPeriodDate());
|
||||
snapshot.setPredictedOvulationDate(estimate.predictedOvulationDate());
|
||||
@@ -209,6 +224,26 @@ public class PeriodCompanionService {
|
||||
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() {
|
||||
return supportMessageRepository.findTop20ByOrderByCreatedAtDesc().stream().map(this::toSupportMessageDto).toList();
|
||||
}
|
||||
@@ -220,11 +255,19 @@ public class PeriodCompanionService {
|
||||
return toSupportMessageDto(supportMessageRepository.save(message));
|
||||
}
|
||||
|
||||
private PredictionDTO latestPrediction(List<PeriodCycle> cycles, LocalDate today) {
|
||||
PredictionEstimate estimate = predictionService.estimate(cycles, today);
|
||||
private PredictionDTO latestPrediction(List<PeriodCycle> cycles, LocalDate today, PeriodSettings settings) {
|
||||
PredictionEstimate estimate = predictionService.estimate(
|
||||
cycles,
|
||||
today,
|
||||
settings.getAverageCycleLength(),
|
||||
settings.getAveragePeriodLength());
|
||||
return predictionRepository.findFirstByOrderByGeneratedAtDesc()
|
||||
.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) {
|
||||
@@ -313,6 +356,10 @@ public class PeriodCompanionService {
|
||||
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) {
|
||||
requiredDate(from, "from");
|
||||
requiredDate(to, "to");
|
||||
@@ -348,6 +395,12 @@ public class PeriodCompanionService {
|
||||
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) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
@@ -15,12 +15,16 @@ import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class PeriodPredictionService {
|
||||
private static final int DEFAULT_CYCLE_LENGTH = 28;
|
||||
private static final int DEFAULT_PERIOD_LENGTH = 5;
|
||||
public static final int DEFAULT_CYCLE_LENGTH = 28;
|
||||
public static final int DEFAULT_PERIOD_LENGTH = 5;
|
||||
|
||||
public PredictionEstimate estimate(List<PeriodCycle> cycles, LocalDate today) {
|
||||
int averageCycleLength = averageCycleLength(cycles);
|
||||
int averagePeriodLength = averagePeriodLength(cycles);
|
||||
return estimate(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH);
|
||||
}
|
||||
|
||||
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()
|
||||
.map(PeriodCycle::getStartDate)
|
||||
.max(Comparator.naturalOrder())
|
||||
@@ -42,7 +46,11 @@ public class PeriodPredictionService {
|
||||
}
|
||||
|
||||
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()
|
||||
.filter(cycle -> !cycle.getStartDate().isAfter(today))
|
||||
.max(Comparator.comparing(PeriodCycle::getStartDate));
|
||||
@@ -93,20 +101,20 @@ public class PeriodPredictionService {
|
||||
cycle.getUpdatedAt());
|
||||
}
|
||||
|
||||
private int averageCycleLength(List<PeriodCycle> cycles) {
|
||||
private int averageCycleLength(List<PeriodCycle> cycles, int fallbackCycleLength) {
|
||||
List<Integer> values = cycles.stream()
|
||||
.map(PeriodCycle::getCycleLength)
|
||||
.filter(length -> length != null && length >= 15 && length <= 60)
|
||||
.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()
|
||||
.map(PeriodCycle::getPeriodLength)
|
||||
.filter(length -> length != null && length >= 1 && length <= 15)
|
||||
.toList();
|
||||
return roundedAverage(values, DEFAULT_PERIOD_LENGTH);
|
||||
return roundedAverage(values, fallbackPeriodLength);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@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
|
||||
public void estimateUsesHistoricalAveragesForFuturePrediction() {
|
||||
LocalDate today = LocalDate.of(2026, 6, 22);
|
||||
|
||||
Reference in New Issue
Block a user