Add period companion wellbeing tools
All checks were successful
Build Org Backend / build (push) Successful in 19s

This commit is contained in:
2026-06-23 10:07:40 +01:00
parent e4f175526f
commit 78515fb468
8 changed files with 375 additions and 0 deletions

View File

@@ -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<FastingLogDTO> getFastingLogs() {
return periodCompanionService.getFastingLogs();
}
@Operation(summary = "Create or update a fasting make-up log")
@PostMapping("/fasting-logs")
public ResponseEntity<FastingLogDTO> saveFastingLog(@RequestBody FastingLogRequest request) {
return ResponseEntity.ok(periodCompanionService.saveFastingLog(request));
}
@Operation(summary = "Create or update a daily reflection")
@PostMapping("/reflections")
public ResponseEntity<ReflectionDTO> saveReflection(@RequestBody ReflectionRequest request) {
return ResponseEntity.ok(periodCompanionService.saveReflection(request));
}
@Operation(summary = "Get period companion settings")
@GetMapping("/settings")
public SettingsDTO getSettings() {

View File

@@ -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<ReflectionDTO> recentReflections,
List<FastingLogDTO> fastingLogs,
long missedFastCount,
long madeUpFastCount) {}
}

View File

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

View File

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

View File

@@ -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<PeriodDailyReflection, Long> {
Optional<PeriodDailyReflection> findByDate(LocalDate date);
List<PeriodDailyReflection> findTop14ByOrderByDateDesc();
}

View File

@@ -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<PeriodFastingLog, Long> {
Optional<PeriodFastingLog> findByFastDate(LocalDate fastDate);
List<PeriodFastingLog> findTop50ByOrderByFastDateDesc();
long countByStatus(String status);
}

View File

@@ -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<PeriodCycle> 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<FastingLogDTO> 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<PeriodCycle> 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 todays 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");

View File

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