diff --git a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java index e7d1e8e..9210cce 100644 --- a/src/main/java/org/zaine/app/controller/PeriodCompanionController.java +++ b/src/main/java/org/zaine/app/controller/PeriodCompanionController.java @@ -17,15 +17,20 @@ 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.FastingLogDTO; +import org.zaine.app.dto.PeriodCompanionDTO.FastingLogRequest; 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.ReflectionDTO; +import org.zaine.app.dto.PeriodCompanionDTO.ReflectionRequest; import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO; import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest; import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO; import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; +import org.zaine.app.dto.PeriodCompanionDTO.WellbeingDTO; import org.zaine.app.service.PeriodCompanionService; import java.time.LocalDate; @@ -117,6 +122,30 @@ public class PeriodCompanionController { return periodCompanionService.getInsights(); } + @Operation(summary = "Get cycle-aware wellbeing guidance") + @GetMapping("/wellbeing") + public WellbeingDTO getWellbeing() { + return periodCompanionService.getWellbeing(); + } + + @Operation(summary = "Get fasting make-up logs") + @GetMapping("/fasting-logs") + public List getFastingLogs() { + return periodCompanionService.getFastingLogs(); + } + + @Operation(summary = "Create or update a fasting make-up log") + @PostMapping("/fasting-logs") + public ResponseEntity saveFastingLog(@RequestBody FastingLogRequest request) { + return ResponseEntity.ok(periodCompanionService.saveFastingLog(request)); + } + + @Operation(summary = "Create or update a daily reflection") + @PostMapping("/reflections") + public ResponseEntity saveReflection(@RequestBody ReflectionRequest request) { + return ResponseEntity.ok(periodCompanionService.saveReflection(request)); + } + @Operation(summary = "Get period companion settings") @GetMapping("/settings") public SettingsDTO getSettings() { diff --git a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java index 7986442..195c94f 100644 --- a/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java +++ b/src/main/java/org/zaine/app/dto/PeriodCompanionDTO.java @@ -89,4 +89,19 @@ public final class PeriodCompanionDTO { public record CreateSymptomRequest(String name) {} public record SettingsDTO(int averageCycleLength, int averagePeriodLength, OffsetDateTime updatedAt) {} public record SettingsRequest(Integer averageCycleLength, Integer averagePeriodLength) {} + public record FastingLogDTO(Long id, LocalDate fastDate, String status, String notes, OffsetDateTime createdAt, OffsetDateTime updatedAt) {} + public record FastingLogRequest(LocalDate fastDate, String status, String notes) {} + public record ReflectionDTO(Long id, LocalDate date, String prompt, String response, OffsetDateTime createdAt, OffsetDateTime updatedAt) {} + public record ReflectionRequest(LocalDate date, String prompt, String response) {} + public record WellbeingDTO( + String phase, + String bodyInsight, + String supportiveAction, + String spiritualReminder, + String fiqhNote, + ReflectionDTO todayReflection, + List recentReflections, + List fastingLogs, + long missedFastCount, + long madeUpFastCount) {} } diff --git a/src/main/java/org/zaine/app/model/PeriodDailyReflection.java b/src/main/java/org/zaine/app/model/PeriodDailyReflection.java new file mode 100644 index 0000000..df8ac65 --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodDailyReflection.java @@ -0,0 +1,58 @@ +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_daily_reflections") +public class PeriodDailyReflection { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private LocalDate date; + + @Column(nullable = false) + private String prompt; + + @Column(name = "response", columnDefinition = "text") + private String response; + + @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 getPrompt() { return prompt; } + public void setPrompt(String prompt) { this.prompt = prompt; } + public String getResponse() { return response; } + public void setResponse(String response) { this.response = response; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/src/main/java/org/zaine/app/model/PeriodFastingLog.java b/src/main/java/org/zaine/app/model/PeriodFastingLog.java new file mode 100644 index 0000000..91b6e6b --- /dev/null +++ b/src/main/java/org/zaine/app/model/PeriodFastingLog.java @@ -0,0 +1,58 @@ +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_fasting_logs") +public class PeriodFastingLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "fast_date", nullable = false, unique = true) + private LocalDate fastDate; + + @Column(nullable = false) + private String status; + + @Column(name = "notes", columnDefinition = "text") + private String notes; + + @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 getFastDate() { return fastDate; } + public void setFastDate(LocalDate fastDate) { this.fastDate = fastDate; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java b/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java new file mode 100644 index 0000000..dd95330 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodDailyReflectionRepository.java @@ -0,0 +1,13 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodDailyReflection; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PeriodDailyReflectionRepository extends JpaRepository { + Optional findByDate(LocalDate date); + List findTop14ByOrderByDateDesc(); +} diff --git a/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java b/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java new file mode 100644 index 0000000..0788da7 --- /dev/null +++ b/src/main/java/org/zaine/app/repositories/PeriodFastingLogRepository.java @@ -0,0 +1,14 @@ +package org.zaine.app.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.zaine.app.model.PeriodFastingLog; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PeriodFastingLogRepository extends JpaRepository { + Optional findByFastDate(LocalDate fastDate); + List findTop50ByOrderByFastDateDesc(); + long countByStatus(String status); +} diff --git a/src/main/java/org/zaine/app/service/PeriodCompanionService.java b/src/main/java/org/zaine/app/service/PeriodCompanionService.java index 5dd3e0e..e14671b 100644 --- a/src/main/java/org/zaine/app/service/PeriodCompanionService.java +++ b/src/main/java/org/zaine/app/service/PeriodCompanionService.java @@ -10,26 +10,35 @@ 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.FastingLogDTO; +import org.zaine.app.dto.PeriodCompanionDTO.FastingLogRequest; 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.ReflectionDTO; +import org.zaine.app.dto.PeriodCompanionDTO.ReflectionRequest; import org.zaine.app.dto.PeriodCompanionDTO.SettingsDTO; import org.zaine.app.dto.PeriodCompanionDTO.SettingsRequest; import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageDTO; import org.zaine.app.dto.PeriodCompanionDTO.SupportMessageRequest; import org.zaine.app.dto.PeriodCompanionDTO.SymptomDTO; import org.zaine.app.dto.PeriodCompanionDTO.SymptomTrendDTO; +import org.zaine.app.dto.PeriodCompanionDTO.WellbeingDTO; import org.zaine.app.model.PeriodCycle; +import org.zaine.app.model.PeriodDailyReflection; import org.zaine.app.model.PeriodDailyLog; +import org.zaine.app.model.PeriodFastingLog; import org.zaine.app.model.PeriodPredictionSnapshot; import org.zaine.app.model.PeriodSettings; import org.zaine.app.model.PeriodSupportMessage; import org.zaine.app.model.PeriodSymptom; import org.zaine.app.repositories.PeriodCycleRepository; +import org.zaine.app.repositories.PeriodDailyReflectionRepository; import org.zaine.app.repositories.PeriodDailyLogRepository; +import org.zaine.app.repositories.PeriodFastingLogRepository; import org.zaine.app.repositories.PeriodPredictionSnapshotRepository; import org.zaine.app.repositories.PeriodSettingsRepository; import org.zaine.app.repositories.PeriodSupportMessageRepository; @@ -51,6 +60,8 @@ import java.util.stream.Collectors; public class PeriodCompanionService { private final PeriodCycleRepository cycleRepository; private final PeriodDailyLogRepository dailyLogRepository; + private final PeriodDailyReflectionRepository dailyReflectionRepository; + private final PeriodFastingLogRepository fastingLogRepository; private final PeriodSymptomRepository symptomRepository; private final PeriodPredictionSnapshotRepository predictionRepository; private final PeriodSettingsRepository settingsRepository; @@ -60,6 +71,8 @@ public class PeriodCompanionService { public PeriodCompanionService( PeriodCycleRepository cycleRepository, PeriodDailyLogRepository dailyLogRepository, + PeriodDailyReflectionRepository dailyReflectionRepository, + PeriodFastingLogRepository fastingLogRepository, PeriodSymptomRepository symptomRepository, PeriodPredictionSnapshotRepository predictionRepository, PeriodSettingsRepository settingsRepository, @@ -67,6 +80,8 @@ public class PeriodCompanionService { PeriodPredictionService predictionService) { this.cycleRepository = cycleRepository; this.dailyLogRepository = dailyLogRepository; + this.dailyReflectionRepository = dailyReflectionRepository; + this.fastingLogRepository = fastingLogRepository; this.symptomRepository = symptomRepository; this.predictionRepository = predictionRepository; this.settingsRepository = settingsRepository; @@ -230,6 +245,76 @@ public class PeriodCompanionService { return new InsightsDTO(cycleTrends, symptomTrends, moodTrends); } + public WellbeingDTO getWellbeing() { + LocalDate today = LocalDate.now(); + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + PeriodSettings settings = getOrCreateSettings(); + String phase = predictionService.currentCycle( + cycles, + today, + settings.getAverageCycleLength(), + settings.getAveragePeriodLength()).phase(); + ReflectionDTO todayReflection = dailyReflectionRepository.findByDate(today) + .map(this::toReflectionDto) + .orElseGet(() -> new ReflectionDTO(null, today, defaultReflectionPrompt(phase), null, null, null)); + + return new WellbeingDTO( + phase, + bodyInsight(phase), + supportiveAction(phase), + spiritualReminder(phase), + fiqhNote(phase), + todayReflection, + dailyReflectionRepository.findTop14ByOrderByDateDesc().stream().map(this::toReflectionDto).toList(), + getFastingLogs(), + fastingLogRepository.countByStatus("MISSED"), + fastingLogRepository.countByStatus("MADE_UP")); + } + + public List getFastingLogs() { + return fastingLogRepository.findTop50ByOrderByFastDateDesc().stream().map(this::toFastingLogDto).toList(); + } + + public FastingLogDTO saveFastingLog(FastingLogRequest request) { + if (request == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fasting log payload is required"); + } + LocalDate fastDate = requiredDate(request.fastDate(), "fastDate"); + String status = requiredText(request.status(), "status").toUpperCase(Locale.ROOT); + if (!status.equals("MISSED") && !status.equals("MADE_UP")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "status must be MISSED or MADE_UP"); + } + PeriodFastingLog log = fastingLogRepository.findByFastDate(fastDate).orElseGet(PeriodFastingLog::new); + log.setFastDate(fastDate); + log.setStatus(status); + log.setNotes(blankToNull(request.notes())); + return toFastingLogDto(fastingLogRepository.save(log)); + } + + public ReflectionDTO saveReflection(ReflectionRequest request) { + if (request == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "reflection payload is required"); + } + LocalDate date = request.date() == null ? LocalDate.now() : request.date(); + String prompt = blankToNull(request.prompt()); + if (prompt == null) { + List cycles = cycleRepository.findAllByOrderByStartDateAsc(); + PeriodSettings settings = getOrCreateSettings(); + String phase = predictionService.currentCycle( + cycles, + date, + settings.getAverageCycleLength(), + settings.getAveragePeriodLength()).phase(); + prompt = defaultReflectionPrompt(phase); + } + + PeriodDailyReflection reflection = dailyReflectionRepository.findByDate(date).orElseGet(PeriodDailyReflection::new); + reflection.setDate(date); + reflection.setPrompt(prompt); + reflection.setResponse(blankToNull(request.response())); + return toReflectionDto(dailyReflectionRepository.save(reflection)); + } + public SettingsDTO getSettings() { return toSettingsDto(getOrCreateSettings()); } @@ -366,10 +451,71 @@ public class PeriodCompanionService { return new SupportMessageDTO(message.getId(), message.getAuthorName(), message.getMessage(), message.getCreatedAt()); } + private FastingLogDTO toFastingLogDto(PeriodFastingLog log) { + return new FastingLogDTO(log.getId(), log.getFastDate(), log.getStatus(), log.getNotes(), log.getCreatedAt(), log.getUpdatedAt()); + } + + private ReflectionDTO toReflectionDto(PeriodDailyReflection reflection) { + return new ReflectionDTO( + reflection.getId(), + reflection.getDate(), + reflection.getPrompt(), + reflection.getResponse(), + reflection.getCreatedAt(), + reflection.getUpdatedAt()); + } + private SettingsDTO toSettingsDto(PeriodSettings settings) { return new SettingsDTO(settings.getAverageCycleLength(), settings.getAveragePeriodLength(), settings.getUpdatedAt()); } + private String bodyInsight(String phase) { + return switch (phase) { + case "Menstrual" -> "Energy can be lower during hayd. Rest, warmth, hydration, and lighter plans may feel better."; + case "Follicular" -> "Energy often rebuilds after bleeding. This can be a good time for planning, movement, and fresh routines."; + case "Ovulation" -> "Some people feel more social and alert around ovulation, while others notice cramps or tenderness."; + case "Luteal" -> "Mood, sleep, cravings, and focus can shift in the luteal phase. Gentler expectations can help."; + default -> "Track a few cycles and the guidance will become more specific to your rhythm."; + }; + } + + private String supportiveAction(String phase) { + return switch (phase) { + case "Menstrual" -> "Choose one kind thing for the body today: heat, a slow walk, extra water, or an early night."; + case "Follicular" -> "Pick one task that benefits future you while your energy is returning."; + case "Ovulation" -> "Use the clearer moments for connection, but keep space for rest if your body asks for it."; + case "Luteal" -> "Reduce friction where possible: prepare simple meals, lower the pace, and write down what feels heavy."; + default -> "Make one small note about what your body is telling you today."; + }; + } + + private String spiritualReminder(String phase) { + return switch (phase) { + case "Menstrual" -> "Even when salah and fasting pause, dhikr, dua, gratitude, and reflection remain open doors."; + case "Follicular" -> "As energy returns, renew intentions gently rather than trying to catch up all at once."; + case "Ovulation" -> "Use moments of ease for shukr and sincere dua."; + case "Luteal" -> "When emotions feel louder, small consistent remembrance can be grounding."; + default -> "Notice the body as an amanah and meet it with mercy."; + }; + } + + private String fiqhNote(String phase) { + if ("Menstrual".equals(phase)) { + return "Hayd-related rulings can vary by madhhab and situation. For spotting, istihadah, ghusl, or confusing bleeding, check a trusted scholar or teacher."; + } + return "For fasting make-ups, spotting, istihadah, and ghusl questions, use this as a reminder tool and confirm details with trusted fiqh guidance."; + } + + private String defaultReflectionPrompt(String phase) { + return switch (phase) { + case "Menstrual" -> "What would mercy toward your body look like today?"; + case "Follicular" -> "What intention do you want to rebuild gently this week?"; + case "Ovulation" -> "Where can you use today’s energy with gratitude and balance?"; + case "Luteal" -> "What can you simplify before your next period begins?"; + default -> "What did you notice in your body and heart today?"; + }; + } + private void validateRange(LocalDate from, LocalDate to) { requiredDate(from, "from"); requiredDate(to, "to"); diff --git a/src/main/resources/db/migration/V7__period_companion_wellbeing.sql b/src/main/resources/db/migration/V7__period_companion_wellbeing.sql new file mode 100644 index 0000000..8f3f6e2 --- /dev/null +++ b/src/main/resources/db/migration/V7__period_companion_wellbeing.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS pc_fasting_logs ( + id BIGSERIAL PRIMARY KEY, + fast_date DATE NOT NULL UNIQUE, + status VARCHAR(32) NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_pc_fasting_logs_status CHECK (status IN ('MISSED', 'MADE_UP')) +); + +CREATE INDEX IF NOT EXISTS idx_pc_fasting_logs_fast_date + ON pc_fasting_logs (fast_date DESC); + +CREATE TABLE IF NOT EXISTS pc_daily_reflections ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + prompt VARCHAR(255) NOT NULL, + response TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_pc_daily_reflections_date + ON pc_daily_reflections (date DESC); + +INSERT INTO pc_symptoms (name) +VALUES + ('Acne'), + ('Appetite changes'), + ('Constipation'), + ('Diarrhea'), + ('Dizziness'), + ('Insomnia'), + ('Joint pain'), + ('Mood swings'), + ('Pelvic pain'), + ('Sleepiness'), + ('Spotting'), + ('Tenderness'), + ('Digestive discomfort'), + ('Sugar cravings') +ON CONFLICT (name) DO NOTHING;