From 5ab5b05467d3932b31266f9b2105dc04ae135acc Mon Sep 17 00:00:00 2001 From: Zaine Date: Mon, 22 Jun 2026 16:27:38 +0100 Subject: [PATCH] Add period companion backend --- .../controller/PeriodCompanionController.java | 129 +++++++ .../org/zaine/app/dto/PeriodCompanionDTO.java | 89 +++++ .../java/org/zaine/app/model/PeriodCycle.java | 63 +++ .../org/zaine/app/model/PeriodDailyLog.java | 83 ++++ .../app/model/PeriodPredictionSnapshot.java | 52 +++ .../zaine/app/model/PeriodSupportMessage.java | 40 ++ .../org/zaine/app/model/PeriodSymptom.java | 23 ++ .../repositories/PeriodCycleRepository.java | 17 + .../PeriodDailyLogRepository.java | 14 + .../PeriodPredictionSnapshotRepository.java | 10 + .../PeriodSupportMessageRepository.java | 10 + .../repositories/PeriodSymptomRepository.java | 12 + .../app/service/PeriodCompanionService.java | 362 ++++++++++++++++++ .../app/service/PeriodPredictionService.java | 127 ++++++ .../db/migration/V5__period_companion.sql | 69 ++++ .../service/PeriodPredictionServiceTest.java | 70 ++++ 16 files changed, 1170 insertions(+) create mode 100644 src/main/java/org/zaine/app/controller/PeriodCompanionController.java create mode 100644 src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java create mode 100644 src/main/java/org/zaine/app/model/PeriodCycle.java create mode 100644 src/main/java/org/zaine/app/model/PeriodDailyLog.java create mode 100644 src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java create mode 100644 src/main/java/org/zaine/app/model/PeriodSupportMessage.java create mode 100644 src/main/java/org/zaine/app/model/PeriodSymptom.java create mode 100644 src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java create mode 100644 src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java create mode 100644 src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java create mode 100644 src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java create mode 100644 src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java create mode 100644 src/main/java/org/zaine/app/service/PeriodCompanionService.java create mode 100644 src/main/java/org/zaine/app/service/PeriodPredictionService.java create mode 100644 src/main/resources/db/migration/V5__period_companion.sql create mode 100644 src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java diff --git a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java new file mode 100644 index 0000000..d077737 --- /dev/null +++ b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java @@ -0,0 +1,129 @@ +package org.zaine.app.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.zaine.app.dto.PeriodCompanionDTO.CalendarDayDTO; +import org.zaine.app.dto.PeriodCompanionDTO.CreateSymptomRequest; +import org.zaine.app.dto.PeriodCompanionDTO.DailyLogDTO; +import org.zaine.app.dto.PeriodCompanionDTO.DailyLogRequest; +import org.zaine.app.dto.PeriodCompanionDTO.DashboardDTO; +import org.zaine.app.dto.PeriodCompanionDTO.InsightsDTO; +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.SupportMessageDTO; +import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; +import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; +import org.zaine.app.service.PeriodCompanionService; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/period-companion") +@Tag(name = "Period Companion", description = "Private menstrual cycle tracking for shared visibility") +public class PeriodCompanionController { + private final PeriodCompanionService periodCompanionService; + + public PeriodCompanionController(PeriodCompanionService periodCompanionService) { + this.periodCompanionService = periodCompanionService; + } + + @Operation(summary = "Get period companion dashboard") + @GetMapping("/dashboard") + public DashboardDTO getDashboard() { + return periodCompanionService.getDashboard(); + } + + @Operation(summary = "Get calendar days for a date range") + @GetMapping("/calendar") + public List getCalendar( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return periodCompanionService.getCalendar(from, to); + } + + @Operation(summary = "Get daily logs for a date range") + @GetMapping("/daily-logs") + public List getDailyLogs( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return periodCompanionService.getDailyLogs(from, to); + } + + @Operation(summary = "Get a daily log by date") + @GetMapping("/daily-logs/{date}") + public DailyLogDTO getDailyLog(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + return periodCompanionService.getDailyLog(date); + } + + @Operation(summary = "Create or update a daily log") + @PostMapping("/daily-logs") + public ResponseEntity saveDailyLog(@RequestBody DailyLogRequest request) { + return ResponseEntity.ok(periodCompanionService.saveDailyLog(request)); + } + + @Operation(summary = "Get symptoms") + @GetMapping("/symptoms") + public List getSymptoms() { + return periodCompanionService.getSymptoms(); + } + + @Operation(summary = "Create a symptom") + @PostMapping("/symptoms") + public ResponseEntity createSymptom(@RequestBody CreateSymptomRequest request) { + return ResponseEntity.ok(periodCompanionService.createSymptom(request)); + } + + @Operation(summary = "Get tracked cycles") + @GetMapping("/cycles") + public List getCycles() { + return periodCompanionService.getCycles(); + } + + @Operation(summary = "Create or update a cycle") + @PostMapping("/cycles") + public ResponseEntity saveCycle(@RequestBody PeriodCycleRequest request) { + return ResponseEntity.ok(periodCompanionService.saveCycle(request)); + } + + @Operation(summary = "Create or update a period entry and matching daily flow logs") + @PostMapping("/period-entries") + public ResponseEntity savePeriodEntry(@RequestBody PeriodEntryRequest request) { + return ResponseEntity.ok(periodCompanionService.savePeriodEntry(request)); + } + + @Operation(summary = "Generate and store a prediction snapshot") + @PostMapping("/predictions/generate") + public ResponseEntity generatePrediction() { + return ResponseEntity.ok(periodCompanionService.generatePrediction()); + } + + @Operation(summary = "Get period companion insights") + @GetMapping("/insights") + public InsightsDTO getInsights() { + return periodCompanionService.getInsights(); + } + + @Operation(summary = "Get recent support messages") + @GetMapping("/support-messages") + public List getSupportMessages() { + return periodCompanionService.getSupportMessages(); + } + + @Operation(summary = "Create a support message") + @PostMapping("/support-messages") + public ResponseEntity createSupportMessage(@RequestBody SupportMessageRequest request) { + return ResponseEntity.ok(periodCompanionService.createSupportMessage(request)); + } +} diff --git a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java new file mode 100644 index 0000000..aa2d9e0 --- /dev/null +++ b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java @@ -0,0 +1,89 @@ +package org.zaine.app.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +public final class PeriodCompanionDTO { + private PeriodCompanionDTO() {} + + public record SymptomDTO(Long id, String name) {} + + public record DailyLogDTO( + Long id, + LocalDate date, + String flowLevel, + String mood, + String energyLevel, + String notes, + List symptoms, + OffsetDateTime createdAt, + OffsetDateTime updatedAt) {} + + public record DailyLogRequest( + LocalDate date, + String flowLevel, + String mood, + String energyLevel, + String notes, + List symptoms) {} + + public record PeriodCycleDTO( + Long id, + LocalDate startDate, + LocalDate endDate, + Integer cycleLength, + Integer periodLength, + OffsetDateTime createdAt, + OffsetDateTime updatedAt) {} + + public record PeriodCycleRequest(LocalDate startDate, LocalDate endDate) {} + + public record PeriodEntryRequest(LocalDate startDate, LocalDate endDate, String flowLevel) {} + + public record PredictionDTO( + Long id, + OffsetDateTime generatedAt, + LocalDate predictedPeriodDate, + LocalDate predictedOvulationDate, + LocalDate fertileWindowStart, + LocalDate fertileWindowEnd, + int averageCycleLength, + int averagePeriodLength) {} + + public record CurrentCycleDTO( + Integer day, + String phase, + PeriodCycleDTO cycle, + int averageCycleLength, + int averagePeriodLength) {} + + public record DashboardDTO( + CurrentCycleDTO currentCycle, + PredictionDTO prediction, + DailyLogDTO today, + List supportMessages, + List recentPartnerMessages) {} + + public record CalendarDayDTO( + LocalDate date, + boolean periodDay, + boolean predictedPeriodDay, + boolean fertileWindow, + boolean ovulationDay, + List symptoms, + DailyLogDTO log) {} + + public record CycleTrendDTO(LocalDate startDate, Integer cycleLength, Integer periodLength) {} + public record SymptomTrendDTO(String symptom, long count) {} + public record MoodTrendDTO(LocalDate date, String mood) {} + + public record InsightsDTO( + List cycleTrends, + List symptomTrends, + List moodTrends) {} + + public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {} + public record SupportMessageRequest(String authorName, String message) {} + public record CreateSymptomRequest(String name) {} +} diff --git a/src/main/java/org/zaine/app/model/PeriodCycle.java b/src/main/java/org/zaine/app/model/PeriodCycle.java new file mode 100644 index 0000000..4523fb1 --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodCycle.java @@ -0,0 +1,63 @@ +package org.zaine.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "pc_cycles") +public class PeriodCycle { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "start_date", nullable = false, unique = true) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "cycle_length") + private Integer cycleLength; + + @Column(name = "period_length") + private Integer periodLength; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void onInsert() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + public Long getId() { return id; } + public LocalDate getStartDate() { return startDate; } + public void setStartDate(LocalDate startDate) { this.startDate = startDate; } + public LocalDate getEndDate() { return endDate; } + public void setEndDate(LocalDate endDate) { this.endDate = endDate; } + public Integer getCycleLength() { return cycleLength; } + public void setCycleLength(Integer cycleLength) { this.cycleLength = cycleLength; } + public Integer getPeriodLength() { return periodLength; } + public void setPeriodLength(Integer periodLength) { this.periodLength = periodLength; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/src/main/java/org/zaine/app/model/PeriodDailyLog.java b/src/main/java/org/zaine/app/model/PeriodDailyLog.java new file mode 100644 index 0000000..23a184f --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodDailyLog.java @@ -0,0 +1,83 @@ +package org.zaine.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.LinkedHashSet; +import java.util.Set; + +@Entity +@Table(name = "pc_daily_logs") +public class PeriodDailyLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private LocalDate date; + + @Column(name = "flow_level") + private String flowLevel; + + @Column(name = "mood") + private String mood; + + @Column(name = "energy_level") + private String energyLevel; + + @Column(name = "notes", columnDefinition = "text") + private String notes; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "pc_daily_log_symptoms", + joinColumns = @JoinColumn(name = "daily_log_id"), + inverseJoinColumns = @JoinColumn(name = "symptom_id")) + private Set symptoms = new LinkedHashSet<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void onInsert() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + public Long getId() { return id; } + public LocalDate getDate() { return date; } + public void setDate(LocalDate date) { this.date = date; } + public String getFlowLevel() { return flowLevel; } + public void setFlowLevel(String flowLevel) { this.flowLevel = flowLevel; } + public String getMood() { return mood; } + public void setMood(String mood) { this.mood = mood; } + public String getEnergyLevel() { return energyLevel; } + public void setEnergyLevel(String energyLevel) { this.energyLevel = energyLevel; } + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + public Set getSymptoms() { return symptoms; } + public void setSymptoms(Set symptoms) { this.symptoms = symptoms; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java b/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java new file mode 100644 index 0000000..faa474d --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodPredictionSnapshot.java @@ -0,0 +1,52 @@ +package org.zaine.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "pc_prediction_snapshots") +public class PeriodPredictionSnapshot { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "generated_at", nullable = false, updatable = false) + private OffsetDateTime generatedAt; + + @Column(name = "predicted_period_date", nullable = false) + private LocalDate predictedPeriodDate; + + @Column(name = "predicted_ovulation_date", nullable = false) + private LocalDate predictedOvulationDate; + + @Column(name = "fertile_window_start", nullable = false) + private LocalDate fertileWindowStart; + + @Column(name = "fertile_window_end", nullable = false) + private LocalDate fertileWindowEnd; + + @PrePersist + void onInsert() { + this.generatedAt = OffsetDateTime.now(); + } + + public Long getId() { return id; } + public OffsetDateTime getGeneratedAt() { return generatedAt; } + public void setGeneratedAt(OffsetDateTime generatedAt) { this.generatedAt = generatedAt; } + public LocalDate getPredictedPeriodDate() { return predictedPeriodDate; } + public void setPredictedPeriodDate(LocalDate predictedPeriodDate) { this.predictedPeriodDate = predictedPeriodDate; } + public LocalDate getPredictedOvulationDate() { return predictedOvulationDate; } + public void setPredictedOvulationDate(LocalDate predictedOvulationDate) { this.predictedOvulationDate = predictedOvulationDate; } + public LocalDate getFertileWindowStart() { return fertileWindowStart; } + public void setFertileWindowStart(LocalDate fertileWindowStart) { this.fertileWindowStart = fertileWindowStart; } + public LocalDate getFertileWindowEnd() { return fertileWindowEnd; } + public void setFertileWindowEnd(LocalDate fertileWindowEnd) { this.fertileWindowEnd = fertileWindowEnd; } +} diff --git a/src/main/java/org/zaine/app/model/PeriodSupportMessage.java b/src/main/java/org/zaine/app/model/PeriodSupportMessage.java new file mode 100644 index 0000000..72f5ef6 --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodSupportMessage.java @@ -0,0 +1,40 @@ +package org.zaine.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "pc_support_messages") +public class PeriodSupportMessage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "author_name") + private String authorName; + + @Column(name = "message", nullable = false, columnDefinition = "text") + private String message; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @PrePersist + void onInsert() { + this.createdAt = OffsetDateTime.now(); + } + + public Long getId() { return id; } + public String getAuthorName() { return authorName; } + public void setAuthorName(String authorName) { this.authorName = authorName; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public OffsetDateTime getCreatedAt() { return createdAt; } +} diff --git a/src/main/java/org/zaine/app/model/PeriodSymptom.java b/src/main/java/org/zaine/app/model/PeriodSymptom.java new file mode 100644 index 0000000..c92170d --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodSymptom.java @@ -0,0 +1,23 @@ +package org.zaine.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "pc_symptoms") +public class PeriodSymptom { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + public Long getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java b/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java new file mode 100644 index 0000000..a902afd --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodCycleRepository.java @@ -0,0 +1,17 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodCycle; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PeriodCycleRepository extends JpaRepository { + Optional findByStartDate(LocalDate startDate); + Optional findFirstByOrderByStartDateDesc(); + Optional findFirstByStartDateLessThanOrderByStartDateDesc(LocalDate startDate); + List findByStartDateBetweenOrderByStartDateAsc(LocalDate from, LocalDate to); + List findTop12ByEndDateIsNotNullOrderByStartDateDesc(); + List findAllByOrderByStartDateAsc(); +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java b/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java new file mode 100644 index 0000000..052e589 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodDailyLogRepository.java @@ -0,0 +1,14 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodDailyLog; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PeriodDailyLogRepository extends JpaRepository { + Optional findByDate(LocalDate date); + List findByDateBetweenOrderByDateAsc(LocalDate from, LocalDate to); + List findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to); +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java b/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java new file mode 100644 index 0000000..3c0344f --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodPredictionSnapshotRepository.java @@ -0,0 +1,10 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodPredictionSnapshot; + +import java.util.Optional; + +public interface PeriodPredictionSnapshotRepository extends JpaRepository { + Optional findFirstByOrderByGeneratedAtDesc(); +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java new file mode 100644 index 0000000..6dcd89d --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodSupportMessageRepository.java @@ -0,0 +1,10 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodSupportMessage; + +import java.util.List; + +public interface PeriodSupportMessageRepository extends JpaRepository { + List findTop20ByOrderByCreatedAtDesc(); +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java b/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java new file mode 100644 index 0000000..7250599 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodSymptomRepository.java @@ -0,0 +1,12 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodSymptom; + +import java.util.List; +import java.util.Optional; + +public interface PeriodSymptomRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); + List findAllByOrderByNameAsc(); +} diff --git a/src/main/java/org/zaine/app/service/PeriodCompanionService.java b/src/main/java/org/zaine/app/service/PeriodCompanionService.java new file mode 100644 index 0000000..616e942 --- /dev/null +++ b/src/main/java/org/zaine/app/service/PeriodCompanionService.java @@ -0,0 +1,362 @@ +package org.zaine.app.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.zaine.app.dto.PeriodCompanionDTO.CalendarDayDTO; +import org.zaine.app.dto.PeriodCompanionDTO.CreateSymptomRequest; +import org.zaine.app.dto.PeriodCompanionDTO.CycleTrendDTO; +import org.zaine.app.dto.PeriodCompanionDTO.DailyLogDTO; +import org.zaine.app.dto.PeriodCompanionDTO.DailyLogRequest; +import org.zaine.app.dto.PeriodCompanionDTO.DashboardDTO; +import org.zaine.app.dto.PeriodCompanionDTO.InsightsDTO; +import org.zaine.app.dto.PeriodCompanionDTO.MoodTrendDTO; +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.SupportMessageDTO; +import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; +import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; +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.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.PeriodSupportMessageRepository; +import org.zaine.app.repositories.PeriodSymptomRepository; +import org.zaine.app.service.PeriodPredictionService.PredictionEstimate; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class PeriodCompanionService { + private final PeriodCycleRepository cycleRepository; + private final PeriodDailyLogRepository dailyLogRepository; + private final PeriodSymptomRepository symptomRepository; + private final PeriodPredictionSnapshotRepository predictionRepository; + private final PeriodSupportMessageRepository supportMessageRepository; + private final PeriodPredictionService predictionService; + + public PeriodCompanionService( + PeriodCycleRepository cycleRepository, + PeriodDailyLogRepository dailyLogRepository, + PeriodSymptomRepository symptomRepository, + PeriodPredictionSnapshotRepository predictionRepository, + PeriodSupportMessageRepository supportMessageRepository, + PeriodPredictionService predictionService) { + this.cycleRepository = cycleRepository; + this.dailyLogRepository = dailyLogRepository; + this.symptomRepository = symptomRepository; + this.predictionRepository = predictionRepository; + this.supportMessageRepository = supportMessageRepository; + this.predictionService = predictionService; + } + + public DashboardDTO getDashboard() { + LocalDate today = LocalDate.now(); + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + PredictionDTO prediction = latestPrediction(cycles, today); + DailyLogDTO todayLog = dailyLogRepository.findByDate(today).map(this::toDailyLogDto).orElse(null); + return new DashboardDTO( + predictionService.currentCycle(cycles, today), + prediction, + todayLog, + contextualSupportMessages(prediction, today), + getSupportMessages()); + } + + public List getCalendar(LocalDate from, LocalDate to) { + validateRange(from, to); + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + PredictionDTO prediction = latestPrediction(cycles, LocalDate.now()); + Map logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to) + .stream() + .collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity())); + + List days = new ArrayList<>(); + for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { + PeriodDailyLog log = logs.get(date); + LocalDate currentDate = date; + boolean periodDay = cycles.stream().anyMatch(cycle -> isPeriodDay(cycle, currentDate)); + days.add(new CalendarDayDTO( + date, + periodDay, + isPredictedPeriodDay(prediction, date), + !date.isBefore(prediction.fertileWindowStart()) && !date.isAfter(prediction.fertileWindowEnd()), + date.equals(prediction.predictedOvulationDate()), + log == null ? List.of() : log.getSymptoms().stream().map(PeriodSymptom::getName).sorted().toList(), + log == null ? null : toDailyLogDto(log))); + } + return days; + } + + public List getDailyLogs(LocalDate from, LocalDate to) { + validateRange(from, to); + return dailyLogRepository.findByDateBetweenOrderByDateDesc(from, to).stream().map(this::toDailyLogDto).toList(); + } + + public DailyLogDTO getDailyLog(LocalDate date) { + return dailyLogRepository.findByDate(date).map(this::toDailyLogDto).orElse(null); + } + + @Transactional + public DailyLogDTO saveDailyLog(DailyLogRequest request) { + LocalDate date = request.date() == null ? LocalDate.now() : request.date(); + PeriodDailyLog log = dailyLogRepository.findByDate(date).orElseGet(() -> { + PeriodDailyLog newLog = new PeriodDailyLog(); + newLog.setDate(date); + return newLog; + }); + log.setFlowLevel(blankToNull(request.flowLevel())); + log.setMood(blankToNull(request.mood())); + log.setEnergyLevel(blankToNull(request.energyLevel())); + log.setNotes(blankToNull(request.notes())); + log.setSymptoms(resolveSymptoms(request.symptoms())); + return toDailyLogDto(dailyLogRepository.save(log)); + } + + public List getSymptoms() { + return symptomRepository.findAllByOrderByNameAsc().stream().map(this::toSymptomDto).toList(); + } + + public SymptomDTO createSymptom(CreateSymptomRequest request) { + return toSymptomDto(resolveSymptom(requiredText(request.name(), "name"))); + } + + @Transactional + public PeriodCycleDTO saveCycle(PeriodCycleRequest request) { + LocalDate startDate = requiredDate(request.startDate(), "startDate"); + LocalDate endDate = request.endDate(); + validateCycleDates(startDate, endDate); + PeriodCycle cycle = cycleRepository.findByStartDate(startDate).orElseGet(PeriodCycle::new); + cycle.setStartDate(startDate); + cycle.setEndDate(endDate); + setDerivedCycleValues(cycle); + PeriodCycle saved = cycleRepository.save(cycle); + updatePreviousCycleLength(saved); + return predictionService.toCycleDto(saved); + } + + public List getCycles() { + return cycleRepository.findAllByOrderByStartDateAsc().stream().map(predictionService::toCycleDto).toList(); + } + + @Transactional + public PeriodCycleDTO savePeriodEntry(PeriodEntryRequest request) { + LocalDate startDate = requiredDate(request.startDate(), "startDate"); + LocalDate endDate = request.endDate() == null ? startDate : request.endDate(); + validateCycleDates(startDate, endDate); + PeriodCycleDTO cycle = saveCycle(new PeriodCycleRequest(startDate, endDate)); + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + LocalDate logDate = date; + PeriodDailyLog log = dailyLogRepository.findByDate(logDate).orElseGet(() -> { + PeriodDailyLog newLog = new PeriodDailyLog(); + newLog.setDate(logDate); + return newLog; + }); + log.setFlowLevel(blankToNull(request.flowLevel()) == null ? "Medium" : request.flowLevel().trim()); + dailyLogRepository.save(log); + } + return cycle; + } + + public PredictionDTO generatePrediction() { + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + PredictionEstimate estimate = predictionService.estimate(cycles, LocalDate.now()); + PeriodPredictionSnapshot snapshot = new PeriodPredictionSnapshot(); + snapshot.setPredictedPeriodDate(estimate.predictedPeriodDate()); + snapshot.setPredictedOvulationDate(estimate.predictedOvulationDate()); + snapshot.setFertileWindowStart(estimate.fertileWindowStart()); + snapshot.setFertileWindowEnd(estimate.fertileWindowEnd()); + return predictionService.toPredictionDto(predictionRepository.save(snapshot), estimate); + } + + public InsightsDTO getInsights() { + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + List logs = dailyLogRepository.findByDateBetweenOrderByDateAsc( + LocalDate.now().minusMonths(12), + LocalDate.now().plusMonths(1)); + List cycleTrends = cycles.stream() + .map(cycle -> new CycleTrendDTO(cycle.getStartDate(), cycle.getCycleLength(), cycle.getPeriodLength())) + .toList(); + List symptomTrends = logs.stream() + .flatMap(log -> log.getSymptoms().stream()) + .collect(Collectors.groupingBy(PeriodSymptom::getName, Collectors.counting())) + .entrySet() + .stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(entry -> new SymptomTrendDTO(entry.getKey(), entry.getValue())) + .toList(); + List moodTrends = logs.stream() + .filter(log -> log.getMood() != null) + .map(log -> new MoodTrendDTO(log.getDate(), log.getMood())) + .toList(); + return new InsightsDTO(cycleTrends, symptomTrends, moodTrends); + } + + public List getSupportMessages() { + return supportMessageRepository.findTop20ByOrderByCreatedAtDesc().stream().map(this::toSupportMessageDto).toList(); + } + + public SupportMessageDTO createSupportMessage(SupportMessageRequest request) { + PeriodSupportMessage message = new PeriodSupportMessage(); + message.setAuthorName(blankToNull(request.authorName())); + message.setMessage(requiredText(request.message(), "message")); + return toSupportMessageDto(supportMessageRepository.save(message)); + } + + private PredictionDTO latestPrediction(List cycles, LocalDate today) { + PredictionEstimate estimate = predictionService.estimate(cycles, today); + return predictionRepository.findFirstByOrderByGeneratedAtDesc() + .map(snapshot -> predictionService.toPredictionDto(snapshot, estimate)) + .orElseGet(this::generatePrediction); + } + + private void setDerivedCycleValues(PeriodCycle cycle) { + if (cycle.getEndDate() != null) { + cycle.setPeriodLength((int) ChronoUnit.DAYS.between(cycle.getStartDate(), cycle.getEndDate()) + 1); + } + } + + private void updatePreviousCycleLength(PeriodCycle cycle) { + cycleRepository.findFirstByStartDateLessThanOrderByStartDateDesc(cycle.getStartDate()).ifPresent(previous -> { + previous.setCycleLength((int) ChronoUnit.DAYS.between(previous.getStartDate(), cycle.getStartDate())); + cycleRepository.save(previous); + }); + } + + private boolean isPeriodDay(PeriodCycle cycle, LocalDate date) { + if (cycle.getEndDate() != null) { + return !date.isBefore(cycle.getStartDate()) && !date.isAfter(cycle.getEndDate()); + } + int periodLength = cycle.getPeriodLength() == null ? 5 : cycle.getPeriodLength(); + return !date.isBefore(cycle.getStartDate()) && date.isBefore(cycle.getStartDate().plusDays(periodLength)); + } + + private boolean isPredictedPeriodDay(PredictionDTO prediction, LocalDate date) { + return !date.isBefore(prediction.predictedPeriodDate()) + && date.isBefore(prediction.predictedPeriodDate().plusDays(prediction.averagePeriodLength())); + } + + private List contextualSupportMessages(PredictionDTO prediction, LocalDate today) { + long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate()); + long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate()); + List messages = new ArrayList<>(); + if (daysUntilPeriod >= 0 && daysUntilPeriod <= 3) { + messages.add("Period may be close. Keep things gentle and flexible."); + } + if (daysUntilOvulation >= 0 && daysUntilOvulation <= 3) { + messages.add("Ovulation is approaching. Energy and mood may shift."); + } + if (!today.isBefore(prediction.fertileWindowStart()) && !today.isAfter(prediction.fertileWindowEnd())) { + messages.add("Fertile window is active based on recent cycle history."); + } + if (messages.isEmpty()) { + messages.add("A steady day to check in, notice patterns, and support each other."); + } + return messages; + } + + private LinkedHashSet resolveSymptoms(List names) { + if (names == null) { + return new LinkedHashSet<>(); + } + return names.stream() + .map(name -> name == null ? "" : name.trim()) + .filter(name -> !name.isBlank()) + .map(this::resolveSymptom) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private PeriodSymptom resolveSymptom(String name) { + String normalized = toDisplayName(name); + return symptomRepository.findByNameIgnoreCase(normalized).orElseGet(() -> { + PeriodSymptom symptom = new PeriodSymptom(); + symptom.setName(normalized); + return symptomRepository.save(symptom); + }); + } + + private DailyLogDTO toDailyLogDto(PeriodDailyLog log) { + return new DailyLogDTO( + log.getId(), + log.getDate(), + log.getFlowLevel(), + log.getMood(), + log.getEnergyLevel(), + log.getNotes(), + log.getSymptoms().stream().sorted(Comparator.comparing(PeriodSymptom::getName)).map(this::toSymptomDto).toList(), + log.getCreatedAt(), + log.getUpdatedAt()); + } + + private SymptomDTO toSymptomDto(PeriodSymptom symptom) { + return new SymptomDTO(symptom.getId(), symptom.getName()); + } + + private SupportMessageDTO toSupportMessageDto(PeriodSupportMessage message) { + return new SupportMessageDTO(message.getId(), message.getAuthorName(), message.getMessage(), message.getCreatedAt()); + } + + private void validateRange(LocalDate from, LocalDate to) { + requiredDate(from, "from"); + requiredDate(to, "to"); + if (to.isBefore(from)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "to must be on or after from"); + } + } + + private void validateCycleDates(LocalDate startDate, LocalDate endDate) { + if (endDate != null && endDate.isBefore(startDate)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "endDate must be on or after startDate"); + } + if (endDate != null && ChronoUnit.DAYS.between(startDate, endDate) > 14) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "period length must be 15 days or less"); + } + } + + private LocalDate requiredDate(LocalDate value, String field) { + if (value == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); + } + return value; + } + + private String requiredText(String value, String field) { + String text = blankToNull(value); + if (text == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required"); + } + if (text.length() > 500) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " must be 500 characters or fewer"); + } + return text; + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String toDisplayName(String value) { + String trimmed = requiredText(value, "name"); + if (trimmed.length() > 80) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name must be 80 characters or fewer"); + } + return trimmed.substring(0, 1).toUpperCase(Locale.ROOT) + trimmed.substring(1); + } +} diff --git a/src/main/java/org/zaine/app/service/PeriodPredictionService.java b/src/main/java/org/zaine/app/service/PeriodPredictionService.java new file mode 100644 index 0000000..675d1dc --- /dev/null +++ b/src/main/java/org/zaine/app/service/PeriodPredictionService.java @@ -0,0 +1,127 @@ +package org.zaine.app.service; + +import org.springframework.stereotype.Service; +import org.zaine.app.dto.PeriodCompanionDTO.CurrentCycleDTO; +import org.zaine.app.dto.PeriodCompanionDTO.PeriodCycleDTO; +import org.zaine.app.dto.PeriodCompanionDTO.PredictionDTO; +import org.zaine.app.model.PeriodCycle; +import org.zaine.app.model.PeriodPredictionSnapshot; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +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 PredictionEstimate estimate(List cycles, LocalDate today) { + int averageCycleLength = averageCycleLength(cycles); + int averagePeriodLength = averagePeriodLength(cycles); + LocalDate anchor = cycles.stream() + .map(PeriodCycle::getStartDate) + .max(Comparator.naturalOrder()) + .orElse(today); + + LocalDate predictedPeriodDate = anchor.plusDays(averageCycleLength); + while (!predictedPeriodDate.isAfter(today)) { + predictedPeriodDate = predictedPeriodDate.plusDays(averageCycleLength); + } + + LocalDate predictedOvulationDate = predictedPeriodDate.minusDays(14); + return new PredictionEstimate( + predictedPeriodDate, + predictedOvulationDate, + predictedOvulationDate.minusDays(5), + predictedOvulationDate.plusDays(1), + averageCycleLength, + averagePeriodLength); + } + + public CurrentCycleDTO currentCycle(List cycles, LocalDate today) { + PredictionEstimate estimate = estimate(cycles, today); + Optional current = cycles.stream() + .filter(cycle -> !cycle.getStartDate().isAfter(today)) + .max(Comparator.comparing(PeriodCycle::getStartDate)); + + if (current.isEmpty()) { + return new CurrentCycleDTO(null, "Unknown", null, estimate.averageCycleLength(), estimate.averagePeriodLength()); + } + + PeriodCycle cycle = current.get(); + int day = (int) ChronoUnit.DAYS.between(cycle.getStartDate(), today) + 1; + int ovulationDay = Math.max(1, estimate.averageCycleLength() - 14); + int periodLength = cycle.getPeriodLength() != null ? cycle.getPeriodLength() : estimate.averagePeriodLength(); + + String phase; + if (day <= periodLength) { + phase = "Menstrual"; + } else if (Math.abs(day - ovulationDay) <= 1) { + phase = "Ovulation"; + } else if (day < ovulationDay) { + phase = "Follicular"; + } else { + phase = "Luteal"; + } + + return new CurrentCycleDTO(day, phase, toCycleDto(cycle), estimate.averageCycleLength(), estimate.averagePeriodLength()); + } + + public PredictionDTO toPredictionDto(PeriodPredictionSnapshot snapshot, PredictionEstimate estimate) { + return new PredictionDTO( + snapshot.getId(), + snapshot.getGeneratedAt(), + snapshot.getPredictedPeriodDate(), + snapshot.getPredictedOvulationDate(), + snapshot.getFertileWindowStart(), + snapshot.getFertileWindowEnd(), + estimate.averageCycleLength(), + estimate.averagePeriodLength()); + } + + public PeriodCycleDTO toCycleDto(PeriodCycle cycle) { + return new PeriodCycleDTO( + cycle.getId(), + cycle.getStartDate(), + cycle.getEndDate(), + cycle.getCycleLength(), + cycle.getPeriodLength(), + cycle.getCreatedAt(), + cycle.getUpdatedAt()); + } + + private int averageCycleLength(List cycles) { + List values = cycles.stream() + .map(PeriodCycle::getCycleLength) + .filter(length -> length != null && length >= 15 && length <= 60) + .toList(); + return roundedAverage(values, DEFAULT_CYCLE_LENGTH); + } + + private int averagePeriodLength(List cycles) { + List values = cycles.stream() + .map(PeriodCycle::getPeriodLength) + .filter(length -> length != null && length >= 1 && length <= 15) + .toList(); + return roundedAverage(values, DEFAULT_PERIOD_LENGTH); + } + + private int roundedAverage(List values, int fallback) { + if (values.isEmpty()) { + return fallback; + } + double average = values.stream().mapToInt(Integer::intValue).average().orElse(fallback); + return (int) Math.round(average); + } + + public record PredictionEstimate( + LocalDate predictedPeriodDate, + LocalDate predictedOvulationDate, + LocalDate fertileWindowStart, + LocalDate fertileWindowEnd, + int averageCycleLength, + int averagePeriodLength) {} +} diff --git a/src/main/resources/db/migration/V5__period_companion.sql b/src/main/resources/db/migration/V5__period_companion.sql new file mode 100644 index 0000000..de09433 --- /dev/null +++ b/src/main/resources/db/migration/V5__period_companion.sql @@ -0,0 +1,69 @@ +CREATE TABLE IF NOT EXISTS pc_cycles ( + id BIGSERIAL PRIMARY KEY, + start_date DATE NOT NULL, + end_date DATE, + cycle_length INTEGER, + period_length INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_pc_cycles_dates CHECK (end_date IS NULL OR end_date >= start_date), + CONSTRAINT chk_pc_cycles_cycle_length CHECK (cycle_length IS NULL OR cycle_length BETWEEN 15 AND 60), + CONSTRAINT chk_pc_cycles_period_length CHECK (period_length IS NULL OR period_length BETWEEN 1 AND 15) +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_pc_cycles_start_date ON pc_cycles (start_date); +CREATE INDEX IF NOT EXISTS idx_pc_cycles_start_date ON pc_cycles (start_date DESC); + +CREATE TABLE IF NOT EXISTS pc_daily_logs ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + flow_level VARCHAR(32), + mood VARCHAR(64), + energy_level VARCHAR(32), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_pc_daily_logs_date ON pc_daily_logs (date DESC); + +CREATE TABLE IF NOT EXISTS pc_symptoms ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS pc_daily_log_symptoms ( + daily_log_id BIGINT NOT NULL REFERENCES pc_daily_logs(id) ON DELETE CASCADE, + symptom_id BIGINT NOT NULL REFERENCES pc_symptoms(id) ON DELETE CASCADE, + PRIMARY KEY (daily_log_id, symptom_id) +); + +CREATE TABLE IF NOT EXISTS pc_prediction_snapshots ( + id BIGSERIAL PRIMARY KEY, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + predicted_period_date DATE NOT NULL, + predicted_ovulation_date DATE NOT NULL, + fertile_window_start DATE NOT NULL, + fertile_window_end DATE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_pc_prediction_snapshots_generated_at + ON pc_prediction_snapshots (generated_at DESC); + +CREATE TABLE IF NOT EXISTS pc_support_messages ( + id BIGSERIAL PRIMARY KEY, + author_name VARCHAR(80), + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO pc_symptoms (name) +VALUES + ('Cramps'), + ('Bloating'), + ('Fatigue'), + ('Headache'), + ('Back pain'), + ('Breast tenderness'), + ('Nausea') +ON CONFLICT (name) DO NOTHING; diff --git a/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java new file mode 100644 index 0000000..d34bead --- /dev/null +++ b/src/test/java/org/zaine/app/service/PeriodPredictionServiceTest.java @@ -0,0 +1,70 @@ +package org.zaine.app.service; + +import org.junit.jupiter.api.Test; +import org.zaine.app.dto.PeriodCompanionDTO.CurrentCycleDTO; +import org.zaine.app.model.PeriodCycle; +import org.zaine.app.service.PeriodPredictionService.PredictionEstimate; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class PeriodPredictionServiceTest { + private final PeriodPredictionService service = new PeriodPredictionService(); + + @Test + public void estimateUsesDefaultLengthsWhenNoHistoryExists() { + LocalDate today = LocalDate.of(2026, 6, 22); + + PredictionEstimate estimate = service.estimate(List.of(), today); + + assertEquals(LocalDate.of(2026, 7, 20), estimate.predictedPeriodDate()); + assertEquals(LocalDate.of(2026, 7, 6), estimate.predictedOvulationDate()); + assertEquals(LocalDate.of(2026, 7, 1), estimate.fertileWindowStart()); + assertEquals(LocalDate.of(2026, 7, 7), estimate.fertileWindowEnd()); + assertEquals(28, estimate.averageCycleLength()); + assertEquals(5, estimate.averagePeriodLength()); + } + + @Test + public void estimateUsesHistoricalAveragesForFuturePrediction() { + LocalDate today = LocalDate.of(2026, 6, 22); + PeriodCycle may = cycle(LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 5), 30, 5); + PeriodCycle june = cycle(LocalDate.of(2026, 5, 31), LocalDate.of(2026, 6, 4), 28, 5); + + PredictionEstimate estimate = service.estimate(List.of(may, june), today); + + assertEquals(29, estimate.averageCycleLength()); + assertEquals(LocalDate.of(2026, 6, 29), estimate.predictedPeriodDate()); + assertEquals(LocalDate.of(2026, 6, 15), estimate.predictedOvulationDate()); + } + + @Test + public void currentCycleReturnsMenstrualPhaseDuringPeriodLength() { + PeriodCycle cycle = cycle(LocalDate.of(2026, 6, 20), LocalDate.of(2026, 6, 24), 28, 5); + + CurrentCycleDTO current = service.currentCycle(List.of(cycle), LocalDate.of(2026, 6, 22)); + + assertEquals(3, current.day()); + assertEquals("Menstrual", current.phase()); + } + + @Test + public void currentCycleReturnsUnknownWithoutCycleHistory() { + CurrentCycleDTO current = service.currentCycle(List.of(), LocalDate.of(2026, 6, 22)); + + assertNull(current.day()); + assertEquals("Unknown", current.phase()); + } + + private PeriodCycle cycle(LocalDate startDate, LocalDate endDate, Integer cycleLength, Integer periodLength) { + PeriodCycle cycle = new PeriodCycle(); + cycle.setStartDate(startDate); + cycle.setEndDate(endDate); + cycle.setCycleLength(cycleLength); + cycle.setPeriodLength(periodLength); + return cycle; + } +}