Add period companion backend
All checks were successful
Build Org Backend / build (push) Successful in 11s

This commit is contained in:
2026-06-22 16:27:38 +01:00
parent 118bc4e586
commit 5ab5b05467
16 changed files with 1170 additions and 0 deletions

View File

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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