Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,4 +108,34 @@ public ResponseEntity<RetrospectCreationCycleResponse> getRetrospectCreationCycl
RetrospectCreationCycleResponse response = adminRetrospectService.getRetrospectCreationCycle(startDate, endDate);
return ResponseEntity.ok().body(response);
}

@GetMapping("/admin/retrospect/writing-cycle/distribution")
public ResponseEntity<WritingCycleDistributionResponse> 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<WritingCycleMonthlyTrendResponse> getWritingCycleMonthlyTrend(
@RequestParam(name = "endDate") LocalDateTime endDate) {

return ResponseEntity.ok(adminRetrospectService.getWritingCycleMonthlyTrend(endDate));
}

@GetMapping("/admin/retrospect/funnel")
public ResponseEntity<RetrospectFunnelResponse> 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<CompletionTrendResponse> getCompletionTrend(
@RequestParam(name = "endDate") LocalDateTime endDate) {

return ResponseEntity.ok(adminRetrospectService.getCompletionTrend(endDate));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.layer.admin.retrospect.controller.dto;

import java.util.List;

public record CompletionTrendResponse(List<MonthlyCompletionRate> months) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.layer.admin.retrospect.controller.dto;

public record MonthlyCompletionRate(
String month,
double overallRate,
double teamRate,
double individualRate
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.layer.admin.retrospect.controller.dto;

import java.util.List;

public record MonthlyWritingCycle(String month, List<WritingCycleEntry> distribution) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.layer.admin.retrospect.controller.dto;

import java.util.List;

public record WritingCycleDistributionResponse(
double averageIntervalDays,
List<WritingCycleEntry> distribution
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.layer.admin.retrospect.controller.dto;

public record WritingCycleEntry(String label, double percentage, long userCount) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.layer.admin.retrospect.controller.dto;

import java.util.List;

public record WritingCycleMonthlyTrendResponse(List<MonthlyWritingCycle> months) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,38 @@ List<AdminRetrospectAnswerHistory> findAllByAnswerEndTimeBetweenAndAnswerStartTi
void deleteByMemberIdAndSpaceIdAndRetrospectId(
Long memberId, Long spaceId, Long retrospectId
);

List<AdminRetrospectAnswerHistory> findAllByAnswerEndTimeBetween(
LocalDateTime startTime, LocalDateTime endTime);

List<AdminRetrospectAnswerHistory> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ List<Long> findProceedingSpacesByMember(

Long countAllByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);

List<AdminRetrospect> findAllByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);

@Query("""
SELECT COUNT(r)
FROM AdminRetrospect r
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -471,6 +481,153 @@ private List<CycleDistributionEntry> buildEmptyCycleDistribution() {
.collect(Collectors.toList());
}

public WritingCycleDistributionResponse getWritingCycleDistribution(LocalDateTime startDate, LocalDateTime endDate) {
List<AdminRetrospectAnswerHistory> currentAnswers =
adminRetrospectAnswerRepository.findAllByAnswerEndTimeBetween(startDate, endDate);

if (currentAnswers.isEmpty()) {
return new WritingCycleDistributionResponse(0.0, buildEmptyWritingCycleDistribution());
}

List<AdminRetrospectAnswerHistory> prevAnswers =
adminRetrospectAnswerRepository.findAllByAnswerEndTimeBefore(startDate);

Map<Long, Double> 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<MonthlyWritingCycle> 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<AdminRetrospectAnswerHistory> current =
adminRetrospectAnswerRepository.findAllByAnswerEndTimeBetween(from, to);
List<AdminRetrospectAnswerHistory> prev =
adminRetrospectAnswerRepository.findAllByAnswerEndTimeBefore(from);

Map<Long, Double> 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<MonthlyCompletionRate> 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<AdminRetrospect> retrospects = retrospectRepository.findAllByCreatedAtBetween(from, to);

if (retrospects.isEmpty()) {
months.add(new MonthlyCompletionRate(ym.format(fmt), 0.0, 0.0, 0.0));
continue;
}

Set<Long> allSpaceIds = retrospects.stream().map(AdminRetrospect::getSpaceId).collect(Collectors.toSet());
Map<Long, AdminSpaceCategory> 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<AdminRetrospect> 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<AdminRetrospect> 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<Long, Double> computeMemberWritingAvgGaps(
List<AdminRetrospectAnswerHistory> current,
List<AdminRetrospectAnswerHistory> prev,
LocalDateTime startDate
) {
Map<Long, List<LocalDateTime>> 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<Long> currentMemberIds = current.stream().map(AdminRetrospectAnswerHistory::getMemberId).collect(Collectors.toSet());
Map<Long, Double> memberAvgGapMap = new HashMap<>();

for (Long memberId : currentMemberIds) {
List<LocalDateTime> times = timesByMember.getOrDefault(memberId, Collections.emptyList())
.stream().sorted().toList();
List<Long> 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<WritingCycleEntry> buildWritingCycleDistribution(Map<Long, Double> memberAvgGapMap) {
if (memberAvgGapMap.isEmpty()) return buildEmptyWritingCycleDistribution();

Map<WritingCycleRange, Long> 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<WritingCycleEntry> 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) {
Expand Down
Loading