From 4ea125c598f259d64e873caec905d36827058806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EB=AF=BC=EA=B7=9C?= Date: Sat, 9 May 2026 23:09:30 +0900 Subject: [PATCH] feat(admin): add writing cycle, funnel, and completion trend APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /admin/retrospect/writing-cycle/distribution - GET /admin/retrospect/writing-cycle/monthly-trend (last 6 months) - GET /admin/retrospect/funnel (created → started → quality → submitted) - GET /admin/retrospect/completion-trend (last 12 months, overall/team/individual) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AdminRetrospectController.java | 36 +++- .../dto/CompletionTrendResponse.java | 6 + .../controller/dto/MonthlyCompletionRate.java | 9 + .../controller/dto/MonthlyWritingCycle.java | 6 + .../dto/RetrospectFunnelResponse.java | 12 ++ .../dto/WritingCycleDistributionResponse.java | 9 + .../controller/dto/WritingCycleEntry.java | 4 + .../dto/WritingCycleMonthlyTrendResponse.java | 6 + .../retrospect/enums/WritingCycleRange.java | 25 +++ .../AdminRetrospectAnswerRepository.java | 34 ++++ .../repository/AdminRetrospectRepository.java | 2 + .../service/AdminRetrospectService.java | 157 ++++++++++++++++++ 12 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CompletionTrendResponse.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyCompletionRate.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyWritingCycle.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectFunnelResponse.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleDistributionResponse.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleEntry.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleMonthlyTrendResponse.java create mode 100644 layer-admin/src/main/java/org/layer/admin/retrospect/enums/WritingCycleRange.java 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 71743cf9..8e2b3b19 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 @@ -3,15 +3,19 @@ import java.time.LocalDateTime; import java.util.List; +import org.layer.admin.retrospect.controller.dto.CompletionTrendResponse; import org.layer.admin.retrospect.controller.dto.CumulativeRetrospectCountResponse; 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.RetrospectFunnelResponse; import org.layer.admin.retrospect.controller.dto.RetrospectOverviewResponse; import org.layer.admin.retrospect.controller.dto.RetrospectRetentionResponse; import org.layer.admin.retrospect.controller.dto.RetrospectStayTimeResponse; +import org.layer.admin.retrospect.controller.dto.WritingCycleDistributionResponse; +import org.layer.admin.retrospect.controller.dto.WritingCycleMonthlyTrendResponse; import org.layer.admin.retrospect.service.AdminRetrospectService; -import org.layer.admin.retrospect.controller.dto.ProceedingRetrospectCTRAverageResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -104,4 +108,34 @@ public ResponseEntity getRetrospectCreationCycl RetrospectCreationCycleResponse response = adminRetrospectService.getRetrospectCreationCycle(startDate, endDate); return ResponseEntity.ok().body(response); } + + @GetMapping("/admin/retrospect/writing-cycle/distribution") + public ResponseEntity getWritingCycleDistribution( + @RequestParam(name = "startDate") LocalDateTime startDate, + @RequestParam(name = "endDate") LocalDateTime endDate) { + + return ResponseEntity.ok(adminRetrospectService.getWritingCycleDistribution(startDate, endDate)); + } + + @GetMapping("/admin/retrospect/writing-cycle/monthly-trend") + public ResponseEntity getWritingCycleMonthlyTrend( + @RequestParam(name = "endDate") LocalDateTime endDate) { + + return ResponseEntity.ok(adminRetrospectService.getWritingCycleMonthlyTrend(endDate)); + } + + @GetMapping("/admin/retrospect/funnel") + public ResponseEntity getRetrospectFunnel( + @RequestParam(name = "startDate") LocalDateTime startDate, + @RequestParam(name = "endDate") LocalDateTime endDate) { + + return ResponseEntity.ok(adminRetrospectService.getRetrospectFunnel(startDate, endDate)); + } + + @GetMapping("/admin/retrospect/completion-trend") + public ResponseEntity getCompletionTrend( + @RequestParam(name = "endDate") LocalDateTime endDate) { + + return ResponseEntity.ok(adminRetrospectService.getCompletionTrend(endDate)); + } } diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CompletionTrendResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CompletionTrendResponse.java new file mode 100644 index 00000000..e26b8093 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/CompletionTrendResponse.java @@ -0,0 +1,6 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record CompletionTrendResponse(List months) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyCompletionRate.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyCompletionRate.java new file mode 100644 index 00000000..77e4c37d --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyCompletionRate.java @@ -0,0 +1,9 @@ +package org.layer.admin.retrospect.controller.dto; + +public record MonthlyCompletionRate( + String month, + double overallRate, + double teamRate, + double individualRate +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyWritingCycle.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyWritingCycle.java new file mode 100644 index 00000000..42235364 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/MonthlyWritingCycle.java @@ -0,0 +1,6 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record MonthlyWritingCycle(String month, List distribution) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectFunnelResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectFunnelResponse.java new file mode 100644 index 00000000..439b8770 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/RetrospectFunnelResponse.java @@ -0,0 +1,12 @@ +package org.layer.admin.retrospect.controller.dto; + +public record RetrospectFunnelResponse( + long createdCount, + long startedCount, + double startedRate, + long qualityCount, + double qualityRate, + long submittedCount, + double submittedRate +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleDistributionResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleDistributionResponse.java new file mode 100644 index 00000000..b137b946 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleDistributionResponse.java @@ -0,0 +1,9 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record WritingCycleDistributionResponse( + double averageIntervalDays, + List distribution +) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleEntry.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleEntry.java new file mode 100644 index 00000000..68aab339 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleEntry.java @@ -0,0 +1,4 @@ +package org.layer.admin.retrospect.controller.dto; + +public record WritingCycleEntry(String label, double percentage, long userCount) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleMonthlyTrendResponse.java b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleMonthlyTrendResponse.java new file mode 100644 index 00000000..0eb197c7 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/controller/dto/WritingCycleMonthlyTrendResponse.java @@ -0,0 +1,6 @@ +package org.layer.admin.retrospect.controller.dto; + +import java.util.List; + +public record WritingCycleMonthlyTrendResponse(List months) { +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/enums/WritingCycleRange.java b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/WritingCycleRange.java new file mode 100644 index 00000000..9383bf35 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/enums/WritingCycleRange.java @@ -0,0 +1,25 @@ +package org.layer.admin.retrospect.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum WritingCycleRange { + PERIODIC("주기적 (7일↓)", 0, 7), + BIWEEKLY("격주~월 (8~30일)", 8, 30), + QUARTERLY("분기적 (31~90일)", 31, 90), + IRREGULAR("비정기 (91~180일)", 91, 180), + DORMANT("휴면 (181일+)", 181, Integer.MAX_VALUE); + + private final String label; + private final int minDays; + private final int maxDays; + + public static WritingCycleRange from(double days) { + for (WritingCycleRange range : values()) { + if (days >= range.minDays && days <= range.maxDays) return range; + } + return DORMANT; + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectAnswerRepository.java b/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectAnswerRepository.java index 8aa6300f..075e1c78 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectAnswerRepository.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectAnswerRepository.java @@ -65,4 +65,38 @@ List findAllByAnswerEndTimeBetweenAndAnswerStartTi void deleteByMemberIdAndSpaceIdAndRetrospectId( Long memberId, Long spaceId, Long retrospectId ); + + List findAllByAnswerEndTimeBetween( + LocalDateTime startTime, LocalDateTime endTime); + + List findAllByAnswerEndTimeBefore(LocalDateTime time); + + @Query(""" + SELECT COUNT(DISTINCT a.retrospectId) + FROM AdminRetrospectAnswerHistory a + WHERE a.answerStartTime BETWEEN :start AND :end + """) + long countDistinctRetrospectIdByAnswerStartTimeBetween( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + @Query(""" + SELECT COUNT(DISTINCT a.retrospectId) + FROM AdminRetrospectAnswerHistory a + WHERE a.answerEndTime BETWEEN :start AND :end + AND LENGTH(a.answerContent) >= :minLength + """) + long countDistinctRetrospectIdByAnswerEndTimeAndQuality( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("minLength") int minLength); + + @Query(""" + SELECT COUNT(DISTINCT a.retrospectId) + FROM AdminRetrospectAnswerHistory a + WHERE a.answerEndTime BETWEEN :start AND :end + """) + long countDistinctRetrospectIdByAnswerEndTimeBetween( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectRepository.java b/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectRepository.java index a082315e..5502bc16 100644 --- a/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectRepository.java +++ b/layer-admin/src/main/java/org/layer/admin/retrospect/repository/AdminRetrospectRepository.java @@ -23,6 +23,8 @@ List findProceedingSpacesByMember( Long countAllByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + List findAllByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + @Query(""" SELECT COUNT(r) FROM AdminRetrospect r 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 abb76026..dc0eeb81 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 @@ -4,6 +4,8 @@ import java.time.Duration; import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -32,9 +34,17 @@ import org.layer.admin.retrospect.entity.AdminRetrospectHistory; import org.layer.admin.retrospect.entity.AdminRetrospectClick; import org.layer.admin.retrospect.entity.AdminRetrospectImpression; +import org.layer.admin.retrospect.controller.dto.CompletionTrendResponse; +import org.layer.admin.retrospect.controller.dto.MonthlyCompletionRate; +import org.layer.admin.retrospect.controller.dto.MonthlyWritingCycle; +import org.layer.admin.retrospect.controller.dto.RetrospectFunnelResponse; +import org.layer.admin.retrospect.controller.dto.WritingCycleDistributionResponse; +import org.layer.admin.retrospect.controller.dto.WritingCycleEntry; +import org.layer.admin.retrospect.controller.dto.WritingCycleMonthlyTrendResponse; 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.enums.WritingCycleRange; import org.layer.admin.retrospect.repository.AdminRetrospectAnswerRepository; import org.layer.admin.retrospect.repository.AdminRetrospectClickRepository; import org.layer.admin.retrospect.repository.AdminRetrospectImpressionRepository; @@ -471,6 +481,153 @@ private List buildEmptyCycleDistribution() { .collect(Collectors.toList()); } + public WritingCycleDistributionResponse getWritingCycleDistribution(LocalDateTime startDate, LocalDateTime endDate) { + List currentAnswers = + adminRetrospectAnswerRepository.findAllByAnswerEndTimeBetween(startDate, endDate); + + if (currentAnswers.isEmpty()) { + return new WritingCycleDistributionResponse(0.0, buildEmptyWritingCycleDistribution()); + } + + List prevAnswers = + adminRetrospectAnswerRepository.findAllByAnswerEndTimeBefore(startDate); + + Map memberAvgGapMap = computeMemberWritingAvgGaps(currentAnswers, prevAnswers, startDate); + double overall = memberAvgGapMap.values().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + + return new WritingCycleDistributionResponse(overall, buildWritingCycleDistribution(memberAvgGapMap)); + } + + public WritingCycleMonthlyTrendResponse getWritingCycleMonthlyTrend(LocalDateTime endDate) { + DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM"); + YearMonth endMonth = YearMonth.from(endDate); + List months = new ArrayList<>(); + + for (int i = 5; i >= 0; i--) { + YearMonth ym = endMonth.minusMonths(i); + LocalDateTime from = ym.atDay(1).atStartOfDay(); + LocalDateTime to = ym.atEndOfMonth().atTime(23, 59, 59); + + List current = + adminRetrospectAnswerRepository.findAllByAnswerEndTimeBetween(from, to); + List prev = + adminRetrospectAnswerRepository.findAllByAnswerEndTimeBefore(from); + + Map memberMap = computeMemberWritingAvgGaps(current, prev, from); + months.add(new MonthlyWritingCycle(ym.format(fmt), buildWritingCycleDistribution(memberMap))); + } + + return new WritingCycleMonthlyTrendResponse(months); + } + + public RetrospectFunnelResponse getRetrospectFunnel(LocalDateTime startDate, LocalDateTime endDate) { + long created = retrospectRepository.countAllByCreatedAtBetween(startDate, endDate); + long started = adminRetrospectAnswerRepository.countDistinctRetrospectIdByAnswerStartTimeBetween(startDate, endDate); + long quality = adminRetrospectAnswerRepository.countDistinctRetrospectIdByAnswerEndTimeAndQuality(startDate, endDate, 10); + long submitted = adminRetrospectAnswerRepository.countDistinctRetrospectIdByAnswerEndTimeBetween(startDate, endDate); + + double startedRate = created == 0 ? 0.0 : started * 100.0 / created; + double qualityRate = created == 0 ? 0.0 : quality * 100.0 / created; + double submittedRate = created == 0 ? 0.0 : submitted * 100.0 / created; + + return new RetrospectFunnelResponse(created, started, startedRate, quality, qualityRate, submitted, submittedRate); + } + + public CompletionTrendResponse getCompletionTrend(LocalDateTime endDate) { + DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM"); + YearMonth endMonth = YearMonth.from(endDate); + List months = new ArrayList<>(); + + for (int i = 11; i >= 0; i--) { + YearMonth ym = endMonth.minusMonths(i); + LocalDateTime from = ym.atDay(1).atStartOfDay(); + LocalDateTime to = ym.atEndOfMonth().atTime(23, 59, 59); + + List retrospects = retrospectRepository.findAllByCreatedAtBetween(from, to); + + if (retrospects.isEmpty()) { + months.add(new MonthlyCompletionRate(ym.format(fmt), 0.0, 0.0, 0.0)); + continue; + } + + Set allSpaceIds = retrospects.stream().map(AdminRetrospect::getSpaceId).collect(Collectors.toSet()); + Map categoryMap = adminSpaceRepository.findAllBySpaceIdIn(allSpaceIds) + .stream() + .collect(Collectors.toMap(AdminSpaceHistory::getSpaceId, AdminSpaceHistory::getCategory, (a, b) -> a)); + + long totalCreated = retrospects.size(); + long totalDone = retrospects.stream().filter(r -> AdminRetrospectStatus.DONE == r.getRetrospectStatus()).count(); + double overallRate = totalCreated == 0 ? 0.0 : totalDone * 100.0 / totalCreated; + + List teamRetros = retrospects.stream() + .filter(r -> AdminSpaceCategory.TEAM == categoryMap.get(r.getSpaceId())) + .toList(); + long teamDone = teamRetros.stream().filter(r -> AdminRetrospectStatus.DONE == r.getRetrospectStatus()).count(); + double teamRate = teamRetros.isEmpty() ? 0.0 : teamDone * 100.0 / teamRetros.size(); + + List indRetros = retrospects.stream() + .filter(r -> AdminSpaceCategory.INDIVIDUAL == categoryMap.get(r.getSpaceId())) + .toList(); + long indDone = indRetros.stream().filter(r -> AdminRetrospectStatus.DONE == r.getRetrospectStatus()).count(); + double indRate = indRetros.isEmpty() ? 0.0 : indDone * 100.0 / indRetros.size(); + + months.add(new MonthlyCompletionRate(ym.format(fmt), overallRate, teamRate, indRate)); + } + + return new CompletionTrendResponse(months); + } + + private Map computeMemberWritingAvgGaps( + List current, + List prev, + LocalDateTime startDate + ) { + Map> timesByMember = new HashMap<>(); + prev.stream() + .filter(a -> a.getAnswerEndTime() != null) + .forEach(a -> timesByMember.computeIfAbsent(a.getMemberId(), k -> new ArrayList<>()).add(a.getAnswerEndTime())); + current.stream() + .filter(a -> a.getAnswerEndTime() != null) + .forEach(a -> timesByMember.computeIfAbsent(a.getMemberId(), k -> new ArrayList<>()).add(a.getAnswerEndTime())); + + Set currentMemberIds = current.stream().map(AdminRetrospectAnswerHistory::getMemberId).collect(Collectors.toSet()); + Map memberAvgGapMap = new HashMap<>(); + + for (Long memberId : currentMemberIds) { + List times = timesByMember.getOrDefault(memberId, Collections.emptyList()) + .stream().sorted().toList(); + List gaps = new ArrayList<>(); + for (int i = 1; i < times.size(); i++) { + if (times.get(i).isBefore(startDate)) continue; + long days = Duration.between(times.get(i - 1), times.get(i)).toDays(); + if (days > 0) gaps.add(days); + } + if (!gaps.isEmpty()) { + memberAvgGapMap.put(memberId, gaps.stream().mapToLong(Long::longValue).average().orElse(0.0)); + } + } + return memberAvgGapMap; + } + + private List buildWritingCycleDistribution(Map memberAvgGapMap) { + if (memberAvgGapMap.isEmpty()) return buildEmptyWritingCycleDistribution(); + + Map buckets = new LinkedHashMap<>(); + for (WritingCycleRange r : WritingCycleRange.values()) buckets.put(r, 0L); + memberAvgGapMap.values().forEach(avg -> buckets.merge(WritingCycleRange.from(avg), 1L, Long::sum)); + + double total = memberAvgGapMap.size(); + return Arrays.stream(WritingCycleRange.values()) + .map(r -> new WritingCycleEntry(r.getLabel(), Math.round(buckets.get(r) / total * 1000.0) / 10.0, buckets.get(r))) + .collect(Collectors.toList()); + } + + private List buildEmptyWritingCycleDistribution() { + return Arrays.stream(WritingCycleRange.values()) + .map(r -> new WritingCycleEntry(r.getLabel(), 0.0, 0L)) + .collect(Collectors.toList()); + } + @Transactional(propagation = REQUIRES_NEW) @Async public void saveRetrospectAnswerHistory(AnswerRetrospectStartEvent event) {