Add period companion backend
All checks were successful
Build Org Backend / build (push) Successful in 11s
All checks were successful
Build Org Backend / build (push) Successful in 11s
This commit is contained in:
@@ -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<CalendarDayDTO> 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<DailyLogDTO> 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<DailyLogDTO> saveDailyLog(@RequestBody DailyLogRequest request) {
|
||||
return ResponseEntity.ok(periodCompanionService.saveDailyLog(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get symptoms")
|
||||
@GetMapping("/symptoms")
|
||||
public List<SymptomDTO> getSymptoms() {
|
||||
return periodCompanionService.getSymptoms();
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a symptom")
|
||||
@PostMapping("/symptoms")
|
||||
public ResponseEntity<SymptomDTO> createSymptom(@RequestBody CreateSymptomRequest request) {
|
||||
return ResponseEntity.ok(periodCompanionService.createSymptom(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get tracked cycles")
|
||||
@GetMapping("/cycles")
|
||||
public List<PeriodCycleDTO> getCycles() {
|
||||
return periodCompanionService.getCycles();
|
||||
}
|
||||
|
||||
@Operation(summary = "Create or update a cycle")
|
||||
@PostMapping("/cycles")
|
||||
public ResponseEntity<PeriodCycleDTO> 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<PeriodCycleDTO> savePeriodEntry(@RequestBody PeriodEntryRequest request) {
|
||||
return ResponseEntity.ok(periodCompanionService.savePeriodEntry(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "Generate and store a prediction snapshot")
|
||||
@PostMapping("/predictions/generate")
|
||||
public ResponseEntity<PredictionDTO> 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<SupportMessageDTO> getSupportMessages() {
|
||||
return periodCompanionService.getSupportMessages();
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a support message")
|
||||
@PostMapping("/support-messages")
|
||||
public ResponseEntity<SupportMessageDTO> createSupportMessage(@RequestBody SupportMessageRequest request) {
|
||||
return ResponseEntity.ok(periodCompanionService.createSupportMessage(request));
|
||||
}
|
||||
}
|
||||
89
src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java
Normal file
89
src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java
Normal file
@@ -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<SymptomDTO> symptoms,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt) {}
|
||||
|
||||
public record DailyLogRequest(
|
||||
LocalDate date,
|
||||
String flowLevel,
|
||||
String mood,
|
||||
String energyLevel,
|
||||
String notes,
|
||||
List<String> 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<String> supportMessages,
|
||||
List<SupportMessageDTO> recentPartnerMessages) {}
|
||||
|
||||
public record CalendarDayDTO(
|
||||
LocalDate date,
|
||||
boolean periodDay,
|
||||
boolean predictedPeriodDay,
|
||||
boolean fertileWindow,
|
||||
boolean ovulationDay,
|
||||
List<String> 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<CycleTrendDTO> cycleTrends,
|
||||
List<SymptomTrendDTO> symptomTrends,
|
||||
List<MoodTrendDTO> moodTrends) {}
|
||||
|
||||
public record SupportMessageDTO(Long id, String authorName, String message, OffsetDateTime createdAt) {}
|
||||
public record SupportMessageRequest(String authorName, String message) {}
|
||||
public record CreateSymptomRequest(String name) {}
|
||||
}
|
||||
63
src/main/java/org/zaine/app/model/PeriodCycle.java
Normal file
63
src/main/java/org/zaine/app/model/PeriodCycle.java
Normal file
@@ -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; }
|
||||
}
|
||||
83
src/main/java/org/zaine/app/model/PeriodDailyLog.java
Normal file
83
src/main/java/org/zaine/app/model/PeriodDailyLog.java
Normal file
@@ -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<PeriodSymptom> 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<PeriodSymptom> getSymptoms() { return symptoms; }
|
||||
public void setSymptoms(Set<PeriodSymptom> symptoms) { this.symptoms = symptoms; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
40
src/main/java/org/zaine/app/model/PeriodSupportMessage.java
Normal file
40
src/main/java/org/zaine/app/model/PeriodSupportMessage.java
Normal file
@@ -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; }
|
||||
}
|
||||
23
src/main/java/org/zaine/app/model/PeriodSymptom.java
Normal file
23
src/main/java/org/zaine/app/model/PeriodSymptom.java
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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<PeriodCycle, Long> {
|
||||
Optional<PeriodCycle> findByStartDate(LocalDate startDate);
|
||||
Optional<PeriodCycle> findFirstByOrderByStartDateDesc();
|
||||
Optional<PeriodCycle> findFirstByStartDateLessThanOrderByStartDateDesc(LocalDate startDate);
|
||||
List<PeriodCycle> findByStartDateBetweenOrderByStartDateAsc(LocalDate from, LocalDate to);
|
||||
List<PeriodCycle> findTop12ByEndDateIsNotNullOrderByStartDateDesc();
|
||||
List<PeriodCycle> findAllByOrderByStartDateAsc();
|
||||
}
|
||||
@@ -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<PeriodDailyLog, Long> {
|
||||
Optional<PeriodDailyLog> findByDate(LocalDate date);
|
||||
List<PeriodDailyLog> findByDateBetweenOrderByDateAsc(LocalDate from, LocalDate to);
|
||||
List<PeriodDailyLog> findByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to);
|
||||
}
|
||||
@@ -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<PeriodPredictionSnapshot, Long> {
|
||||
Optional<PeriodPredictionSnapshot> findFirstByOrderByGeneratedAtDesc();
|
||||
}
|
||||
@@ -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<PeriodSupportMessage, Long> {
|
||||
List<PeriodSupportMessage> findTop20ByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -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<PeriodSymptom, Long> {
|
||||
Optional<PeriodSymptom> findByNameIgnoreCase(String name);
|
||||
List<PeriodSymptom> findAllByOrderByNameAsc();
|
||||
}
|
||||
362
src/main/java/org/zaine/app/service/PeriodCompanionService.java
Normal file
362
src/main/java/org/zaine/app/service/PeriodCompanionService.java
Normal file
@@ -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<PeriodCycle> 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<CalendarDayDTO> getCalendar(LocalDate from, LocalDate to) {
|
||||
validateRange(from, to);
|
||||
List<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
||||
PredictionDTO prediction = latestPrediction(cycles, LocalDate.now());
|
||||
Map<LocalDate, PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(from, to)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(PeriodDailyLog::getDate, Function.identity()));
|
||||
|
||||
List<CalendarDayDTO> 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<DailyLogDTO> 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<SymptomDTO> 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<PeriodCycleDTO> 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<PeriodCycle> 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<PeriodCycle> cycles = cycleRepository.findAllByOrderByStartDateAsc();
|
||||
List<PeriodDailyLog> logs = dailyLogRepository.findByDateBetweenOrderByDateAsc(
|
||||
LocalDate.now().minusMonths(12),
|
||||
LocalDate.now().plusMonths(1));
|
||||
List<CycleTrendDTO> cycleTrends = cycles.stream()
|
||||
.map(cycle -> new CycleTrendDTO(cycle.getStartDate(), cycle.getCycleLength(), cycle.getPeriodLength()))
|
||||
.toList();
|
||||
List<SymptomTrendDTO> symptomTrends = logs.stream()
|
||||
.flatMap(log -> log.getSymptoms().stream())
|
||||
.collect(Collectors.groupingBy(PeriodSymptom::getName, Collectors.counting()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
||||
.map(entry -> new SymptomTrendDTO(entry.getKey(), entry.getValue()))
|
||||
.toList();
|
||||
List<MoodTrendDTO> 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<SupportMessageDTO> 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<PeriodCycle> 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<String> contextualSupportMessages(PredictionDTO prediction, LocalDate today) {
|
||||
long daysUntilPeriod = ChronoUnit.DAYS.between(today, prediction.predictedPeriodDate());
|
||||
long daysUntilOvulation = ChronoUnit.DAYS.between(today, prediction.predictedOvulationDate());
|
||||
List<String> 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<PeriodSymptom> resolveSymptoms(List<String> 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);
|
||||
}
|
||||
}
|
||||
127
src/main/java/org/zaine/app/service/PeriodPredictionService.java
Normal file
127
src/main/java/org/zaine/app/service/PeriodPredictionService.java
Normal file
@@ -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<PeriodCycle> 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<PeriodCycle> cycles, LocalDate today) {
|
||||
PredictionEstimate estimate = estimate(cycles, today);
|
||||
Optional<PeriodCycle> 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<PeriodCycle> cycles) {
|
||||
List<Integer> 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<PeriodCycle> cycles) {
|
||||
List<Integer> 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<Integer> 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) {}
|
||||
}
|
||||
69
src/main/resources/db/migration/V5__period_companion.sql
Normal file
69
src/main/resources/db/migration/V5__period_companion.sql
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user