diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java index 0629a596..71743cf9 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/AdminRetrospectController.java @@ -6,6 +6,7 @@ import org.layer.admin.retrospect.controller.dto.CumulativeRetrospectCountResponse; import org.layer.admin.retrospect.controller.dto.MeaningfulRetrospectMemberResponse; import org.layer.admin.retrospect.controller.dto.RetrospectCompletionRateResponse; +import org.layer.admin.retrospect.controller.dto.RetrospectCreationCycleResponse; import org.layer.admin.retrospect.controller.dto.RetrospectOverviewResponse; import org.layer.admin.retrospect.controller.dto.RetrospectRetentionResponse; import org.layer.admin.retrospect.controller.dto.RetrospectStayTimeResponse; @@ -94,4 +95,13 @@ public ResponseEntity getProceedingCTR( ProceedingRetrospectCTRAverageResponse response = adminRetrospectService.getProceedingRetrospectCTR(startDate, endDate); return ResponseEntity.ok().body(response); } + + @GetMapping("/admin/retrospect/creation-cycle") + public ResponseEntity getRetrospectCreationCycle( + @RequestParam(name = "startDate") LocalDateTime startDate, + @RequestParam(name = "endDate") LocalDateTime endDate) { + + RetrospectCreationCycleResponse response = adminRetrospectService.getRetrospectCreationCycle(startDate, endDate); + return ResponseEntity.ok().body(response); + } } diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java new file mode 100644 index 00000000..d574d27c --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CycleDistributionEntry.java @@ -0,0 +1,7 @@ +package org.layer.admin.retrospect.controller.dto; + +public record CycleDistributionEntry( + String label, + double percentage +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java new file mode 100644 index 00000000..7e640feb --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectCreationCycleResponse.java @@ -0,0 +1,11 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record RetrospectCreationCycleResponse( + double overallAverageDays, + double teamAverageDays, + double individualAverageDays, + List distribution +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java new file mode 100644 index 00000000..b6966b5c --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/RetrospectCycleRange.java @@ -0,0 +1,24 @@ +package org.layer.admin.retrospect.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum RetrospectCycleRange { + DAY_1_TO_3("1-3일"), + DAY_4_TO_7("4-7일"), + DAY_8_TO_14("8-14일"), + DAY_15_TO_30("15-30일"), + DAY_31_PLUS("31일+"); + + private final String label; + + public static RetrospectCycleRange from(double days) { + if (days <= 3) return DAY_1_TO_3; + if (days <= 7) return DAY_4_TO_7; + if (days <= 14) return DAY_8_TO_14; + if (days <= 30) return DAY_15_TO_30; + return DAY_31_PLUS; + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java b/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java index f1f6f0f8..abb76026 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/service/AdminRetrospectService.java @@ -5,10 +5,12 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,9 +19,11 @@ import org.layer.admin.member.repository.AdminMemberRepository; import org.layer.admin.retrospect.controller.dto.CumulativeRetrospectCountResponse; +import org.layer.admin.retrospect.controller.dto.CycleDistributionEntry; import org.layer.admin.retrospect.controller.dto.MeaningfulRetrospectMemberResponse; import org.layer.admin.retrospect.controller.dto.ProceedingRetrospectCTRAverageResponse; import org.layer.admin.retrospect.controller.dto.RetrospectCompletionRateResponse; +import org.layer.admin.retrospect.controller.dto.RetrospectCreationCycleResponse; import org.layer.admin.retrospect.controller.dto.RetrospectOverviewResponse; import org.layer.admin.retrospect.controller.dto.RetrospectRetentionResponse; import org.layer.admin.retrospect.controller.dto.RetrospectStayTimeResponse; @@ -30,6 +34,7 @@ import org.layer.admin.retrospect.entity.AdminRetrospectImpression; import org.layer.admin.retrospect.enums.AdminRetrospectStatus; import org.layer.admin.retrospect.enums.AnswerTimeRange; +import org.layer.admin.retrospect.enums.RetrospectCycleRange; import org.layer.admin.retrospect.repository.AdminRetrospectAnswerRepository; import org.layer.admin.retrospect.repository.AdminRetrospectClickRepository; import org.layer.admin.retrospect.repository.AdminRetrospectImpressionRepository; @@ -40,6 +45,8 @@ import org.layer.admin.retrospect.repository.dto.RetrospectAnswerCompletionDto; import org.layer.admin.retrospect.repository.dto.SpaceRetrospectCountDto; import org.layer.admin.space.entity.AdminMemberSpaceRelation; +import org.layer.admin.space.entity.AdminSpaceHistory; +import org.layer.admin.space.enums.AdminSpaceCategory; import org.layer.admin.space.repository.AdminMemberSpaceRelationRepository; import org.layer.admin.space.repository.AdminSpaceRepository; import org.layer.event.retrospect.ClickRetrospectEvent; @@ -343,6 +350,127 @@ public ProceedingRetrospectCTRAverageResponse getProceedingRetrospectCTR(LocalDa return new ProceedingRetrospectCTRAverageResponse(averageCTR); } + public RetrospectCreationCycleResponse getRetrospectCreationCycle(LocalDateTime startDate, LocalDateTime endDate) { + List currentHistories = adminRetrospectHistoryRepository.findAllByEventTimeBetween(startDate, endDate); + + if (currentHistories.isEmpty()) { + return new RetrospectCreationCycleResponse(0.0, 0.0, 0.0, buildEmptyCycleDistribution()); + } + + List prevHistories = adminRetrospectHistoryRepository.findAllByEventTimeBefore(startDate); + + Set allSpaceIds = new HashSet<>(); + currentHistories.forEach(h -> allSpaceIds.add(h.getSpaceId())); + prevHistories.forEach(h -> allSpaceIds.add(h.getSpaceId())); + + Map spaceCategoryMap = adminSpaceRepository.findAllBySpaceIdIn(allSpaceIds) + .stream() + .collect(Collectors.toMap(AdminSpaceHistory::getSpaceId, AdminSpaceHistory::getCategory, (a, b) -> a)); + + Map> allHistoriesByMember = new HashMap<>(); + prevHistories.forEach(h -> allHistoriesByMember.computeIfAbsent(h.getMemberId(), k -> new ArrayList<>()).add(h)); + currentHistories.forEach(h -> allHistoriesByMember.computeIfAbsent(h.getMemberId(), k -> new ArrayList<>()).add(h)); + + Set currentMemberIds = currentHistories.stream() + .map(AdminRetrospectHistory::getMemberId) + .collect(Collectors.toSet()); + + List allGaps = new ArrayList<>(); + List teamGaps = new ArrayList<>(); + List individualGaps = new ArrayList<>(); + Map memberAvgGapMap = new HashMap<>(); + + for (Long memberId : currentMemberIds) { + List memberHistories = allHistoriesByMember.getOrDefault(memberId, Collections.emptyList()); + memberHistories.sort(Comparator.comparing(AdminRetrospectHistory::getEventTime)); + + List memberAllGaps = new ArrayList<>(); + for (int i = 1; i < memberHistories.size(); i++) { + AdminRetrospectHistory curr = memberHistories.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(memberHistories.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays <= 0) { + continue; + } + memberAllGaps.add(gapDays); + allGaps.add(gapDays); + } + + if (!memberAllGaps.isEmpty()) { + memberAvgGapMap.put(memberId, memberAllGaps.stream().mapToLong(Long::longValue).average().orElse(0.0)); + } + + List teamRetros = memberHistories.stream() + .filter(h -> AdminSpaceCategory.TEAM == spaceCategoryMap.get(h.getSpaceId())) + .sorted(Comparator.comparing(AdminRetrospectHistory::getEventTime)) + .toList(); + + for (int i = 1; i < teamRetros.size(); i++) { + AdminRetrospectHistory curr = teamRetros.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(teamRetros.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays > 0) { + teamGaps.add(gapDays); + } + } + + List individualRetros = memberHistories.stream() + .filter(h -> AdminSpaceCategory.INDIVIDUAL == spaceCategoryMap.get(h.getSpaceId())) + .sorted(Comparator.comparing(AdminRetrospectHistory::getEventTime)) + .toList(); + + for (int i = 1; i < individualRetros.size(); i++) { + AdminRetrospectHistory curr = individualRetros.get(i); + if (curr.getEventTime().isBefore(startDate)) { + continue; + } + long gapDays = Duration.between(individualRetros.get(i - 1).getEventTime(), curr.getEventTime()).toDays(); + if (gapDays > 0) { + individualGaps.add(gapDays); + } + } + } + + double overallAvg = allGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + double teamAvg = teamGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + double individualAvg = individualGaps.stream().mapToLong(Long::longValue).average().orElse(0.0); + + return new RetrospectCreationCycleResponse(overallAvg, teamAvg, individualAvg, buildCycleDistribution(memberAvgGapMap)); + } + + private List buildCycleDistribution(Map memberAvgGapMap) { + if (memberAvgGapMap.isEmpty()) { + return buildEmptyCycleDistribution(); + } + + Map bucketCounts = new LinkedHashMap<>(); + for (RetrospectCycleRange range : RetrospectCycleRange.values()) { + bucketCounts.put(range, 0L); + } + + memberAvgGapMap.values().forEach(avgGap -> + bucketCounts.merge(RetrospectCycleRange.from(avgGap), 1L, Long::sum) + ); + + double total = memberAvgGapMap.size(); + return Arrays.stream(RetrospectCycleRange.values()) + .map(range -> new CycleDistributionEntry( + range.getLabel(), + Math.round(bucketCounts.get(range) / total * 1000.0) / 10.0 + )) + .collect(Collectors.toList()); + } + + private List buildEmptyCycleDistribution() { + return Arrays.stream(RetrospectCycleRange.values()) + .map(range -> new CycleDistributionEntry(range.getLabel(), 0.0)) + .collect(Collectors.toList()); + } + @Transactional(propagation = REQUIRES_NEW) @Async public void saveRetrospectAnswerHistory(AnswerRetrospectStartEvent event) { diff --git a/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java b/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java index e577c337..45093dfb 100644 --- a/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java +++ b/layer-admin/src/main/java/org/layer/admin/space/repository/AdminSpaceRepository.java @@ -1,6 +1,7 @@ package org.layer.admin.space.repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.layer.admin.space.controller.dto.SpaceCountResponse; @@ -26,4 +27,6 @@ List findAllByCategory( Long countAllByEventTimeBetween( @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + List findAllBySpaceIdIn(Collection spaceIds); }