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