diff --git a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java index d077737..e7d1e8e 100644 --- a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java +++ b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java @@ -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 updateSettings(@RequestBody SettingsRequest request) { + return ResponseEntity.ok(periodCompanionService.updateSettings(request)); + } + @Operation(summary = "Get recent support messages") @GetMapping("/support-messages") public List getSupportMessages() { diff --git a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java index aa2d9e0..7986442 100644 --- a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java +++ b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java @@ -62,6 +62,7 @@ public final class PeriodCompanionDTO { CurrentCycleDTO currentCycle, PredictionDTO prediction, DailyLogDTO today, + SettingsDTO settings, List supportMessages, List 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) {} } diff --git a/src/main/java/org/zaine/app/model/PeriodSettings.java b/src/main/java/org/zaine/app/model/PeriodSettings.java new file mode 100644 index 0000000..37ba050 --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodSettings.java @@ -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; } +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java new file mode 100644 index 0000000..a486091 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodSettingsRepository.java @@ -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 { +} diff --git a/src/main/java/org/zaine/app/service/PeriodCompanionService.java b/src/main/java/org/zaine/app/service/PeriodCompanionService.java index 616e942..4147775 100644 --- a/src/main/java/org/zaine/app/service/PeriodCompanionService.java +++ b/src/main/java/org/zaine/app/service/PeriodCompanionService.java @@ -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 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 getCalendar(LocalDate from, LocalDate to) { validateRange(from, to); List cycles = cycleRepository.findAllByOrderByStartDateAsc(); - PredictionDTO prediction = latestPrediction(cycles, LocalDate.now()); + PeriodSettings settings = getOrCreateSettings(); + PredictionDTO prediction = latestPrediction(cycles, LocalDate.now(), settings); Map logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to) .stream() .collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity())); @@ -177,7 +187,12 @@ public class PeriodCompanionService { public PredictionDTO generatePrediction() { List 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 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 cycles, LocalDate today) { - PredictionEstimate estimate = predictionService.estimate(cycles, today); + private PredictionDTO latestPrediction(List 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(); } diff --git a/src/main/java/org/zaine/app/service/PeriodPredictionService.java b/src/main/java/org/zaine/app/service/PeriodPredictionService.java index 675d1dc..db06cf7 100644 --- a/src/main/java/org/zaine/app/service/PeriodPredictionService.java +++ b/src/main/java/org/zaine/app/service/PeriodPredictionService.java @@ -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 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 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 cycles, LocalDate today) { - PredictionEstimate estimate = estimate(cycles, today); + return currentCycle(cycles, today, DEFAULT_CYCLE_LENGTH, DEFAULT_PERIOD_LENGTH); + } + + public CurrentCycleDTO currentCycle(List cycles, LocalDate today, int fallbackCycleLength, int fallbackPeriodLength) { + PredictionEstimate estimate = estimate(cycles, today, fallbackCycleLength, fallbackPeriodLength); Optional 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 cycles) { + private int averageCycleLength(List cycles, int fallbackCycleLength) { List 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 cycles) { + private int averagePeriodLength(List cycles, int fallbackPeriodLength) { List 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 values, int fallback) { diff --git a/src/main/resources/db/migration/V6__period_companion_settings.sql b/src/main/resources/db/migration/V6__period_companion_settings.sql new file mode 100644 index 0000000..0afd3e1 --- /dev/null +++ b/src/main/resources/db/migration/V6__period_companion_settings.sql @@ -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; diff --git a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java index d34bead..d96a406 100644 --- a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java +++ b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java @@ -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);