Add configurable period prediction settings
All checks were successful
Build Org Backend / build (push) Successful in 12s

This commit is contained in:
2026-06-22 17:01:10 +01:00
parent 5ab5b05467
commit 7bae39fe62
8 changed files with 171 additions and 16 deletions

View File

@@ -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() {

View File

@@ -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) {}
}

View 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; }
}

View File

@@ -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> {
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);