diff --git a/src/main/java/com/depromeet/domain/mission/application/MissionService.java b/src/main/java/com/depromeet/domain/mission/application/MissionService.java index ede4dfca8..c4a0f4ae5 100644 --- a/src/main/java/com/depromeet/domain/mission/application/MissionService.java +++ b/src/main/java/com/depromeet/domain/mission/application/MissionService.java @@ -313,7 +313,7 @@ public MissionSummaryListResponse findSummaryList(LocalDate date) { List result = missions.stream() - .map(mission -> getMissionSummaryItem(mission)) + .map(mission -> getMissionSummaryItem(mission, date)) .sorted( Comparator.comparing(MissionSummaryItem::missionStatus) .reversed() @@ -322,12 +322,6 @@ public MissionSummaryListResponse findSummaryList(LocalDate date) { .reversed())) .collect(Collectors.toList()); - result.sort( - Comparator.comparing(MissionSummaryItem::missionStatus) - .reversed() - .thenComparing( - Comparator.comparing(MissionSummaryItem::finishedAt).reversed())); - long missionAllCount = missions.size(); long missionCompleteCount = result.stream() @@ -341,13 +335,17 @@ public MissionSummaryListResponse findSummaryList(LocalDate date) { missionAllCount, missionCompleteCount, missionNoneCount, result); } - private static MissionSummaryItem getMissionSummaryItem(Mission mission) { + private static MissionSummaryItem getMissionSummaryItem(Mission mission, LocalDate date) { boolean isCompleted = mission.getMissionRecords().stream() .anyMatch( missionRecord -> missionRecord.getUploadStatus() - == ImageUploadStatus.COMPLETE); + == ImageUploadStatus.COMPLETE + && missionRecord + .getStartedAt() + .toLocalDate() + .equals(date)); return isCompleted ? MissionSummaryItem.of(mission, MissionStatus.COMPLETED) : MissionSummaryItem.of(mission, MissionStatus.NONE); diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java index ab7726c72..6078145da 100644 --- a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java @@ -57,7 +57,10 @@ public List findMissionsWithRecordsByRelations( jpaQueryFactory .selectFrom(mission) .leftJoin(mission.missionRecords, missionRecord) - .where(memberIdEq(memberId), visibilityByRelations(existsMemberRelations)) + .where( + memberIdEq(memberId), + durationStatusInProgress(), + visibilityByRelations(existsMemberRelations)) .orderBy(mission.id.desc()) .fetchJoin(); return query.fetch(); diff --git a/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java b/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java index d36dc7567..8da722213 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java +++ b/src/main/java/com/depromeet/domain/missionRecord/api/MissionRecordController.java @@ -57,4 +57,10 @@ public ResponseEntity missionRecordInProgressDelete() { missionRecordService.deleteInProgressMissionRecord(); return ResponseEntity.ok().build(); } + + @Operation(summary = "미션별 상세 통계", description = "미션별 통계로 미션에 대한 현황을 파악합니다.") + @GetMapping("/statistics/{missionId}") + public MissionStatisticsResponse missionStatistics(@PathVariable Long missionId) { + return missionRecordService.findMissionStatistics(missionId); + } } diff --git a/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java index b75e62211..c52b50447 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java +++ b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java @@ -1,6 +1,7 @@ package com.depromeet.domain.missionRecord.application; import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.application.MissionService; import com.depromeet.domain.mission.dao.MissionRepository; import com.depromeet.domain.mission.domain.Mission; import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; @@ -11,6 +12,7 @@ import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; import com.depromeet.domain.missionRecord.dto.request.MissionRecordUpdateRequest; import com.depromeet.domain.missionRecord.dto.response.*; +import com.depromeet.domain.missionRecord.dto.response.MissionStatisticsResponse; import com.depromeet.global.error.exception.CustomException; import com.depromeet.global.error.exception.ErrorCode; import com.depromeet.global.util.MemberUtil; @@ -32,6 +34,7 @@ public class MissionRecordService { private static final int DAYS_ADJUSTMENT = 1; private final MemberUtil memberUtil; + private final MissionService missionService; private final MissionRepository missionRepository; private final MissionRecordRepository missionRecordRepository; private final MissionRecordTtlRepository missionRecordTtlRepository; @@ -165,6 +168,85 @@ public void deleteInProgressMissionRecord() { } } + @Transactional(readOnly = true) + public MissionStatisticsResponse findMissionStatistics(Long missionId) { + final Mission mission = findMissionById(missionId); + final LocalDateTime startedAt = mission.getStartedAt(); + final LocalDateTime finishedAt = mission.getFinishedAt(); + final LocalDateTime today = LocalDateTime.now(); + + List missionRecords = + missionRecordRepository.findAllByCompletedMission(missionId); + LocalDateTime endedAt = finishedAt.isBefore(today) ? finishedAt : today; + + // 달성률 + double totalMissionAttainRate = + calculateMissionAttainRate(missionRecords.size(), startedAt, endedAt); + + // 시간표 생성 + List timeTable = generateRecordTimeTable(missionRecords); + + // 최대 연속성 계산 + long maxContinuousSuccessDay = + calculateMaxContinuousSuccessDay(startedAt, finishedAt, missionRecords); + + long totalSymbolStack = 0; + long sumDuration = 0; + for (FocusMissionRecordItem timeOfDay : timeTable) { + totalSymbolStack += timeOfDay.symbolStack(); + sumDuration += timeOfDay.durationMinute(); + } + + // 전체 수행 시간 (시간) + long totalMissionHour = sumDuration / 60; + + // 전체 수행 시간 (분) + long totalMissionMinute = sumDuration % 60; + + return MissionStatisticsResponse.of( + totalMissionHour, + totalMissionMinute, + totalSymbolStack, + maxContinuousSuccessDay, + missionRecords.size(), + totalMissionAttainRate, + startedAt, + finishedAt, + timeTable); + } + + private List generateRecordTimeTable( + List missionRecords) { + return missionRecords.stream().map(FocusMissionRecordItem::from).toList(); + } + + private long calculateMaxContinuousSuccessDay( + LocalDateTime startedAt, LocalDateTime finishedAt, List missionRecords) { + long continuousSuccessDay = 1; + long maxContinuousSuccessDay = 0; + LocalDate previousDate = null; + + for (MissionRecord missionRecord : missionRecords) { + LocalDate currentDate = missionRecord.getStartedAt().toLocalDate(); + + // startedAt과 finishedAt 사이에 있는 일자일 때만 고려 + if (!(currentDate.isAfter(startedAt.toLocalDate()) + && currentDate.isBefore(finishedAt.toLocalDate()))) { + continue; + } + if (previousDate != null && currentDate.minusDays(1).isEqual(previousDate)) { + continuousSuccessDay++; + } else { + continuousSuccessDay = 1; // 연속성이 깨진 경우 초기화 + } + + maxContinuousSuccessDay = Math.max(continuousSuccessDay, maxContinuousSuccessDay); + + previousDate = currentDate; + } + return maxContinuousSuccessDay; + } + private void validateMissionRecordDuration(Duration duration) { if (duration.getSeconds() > 3600L) { throw new CustomException(ErrorCode.MISSION_RECORD_DURATION_OVERBALANCE); @@ -176,4 +258,10 @@ private void validateMissionRecordUserMismatch(Mission mission, Member member) { throw new CustomException(ErrorCode.MISSION_RECORD_USER_MISMATCH); } } + + private double calculateMissionAttainRate( + long completeSize, LocalDateTime startedAt, LocalDateTime endedAt) { + long totalSize = Duration.between(startedAt, endedAt).toDays() + DAYS_ADJUSTMENT; + return Math.round((double) completeSize / totalSize * 1000) / 10.0; + } } diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java index 03ce2853a..aa90b0b00 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java @@ -11,6 +11,8 @@ public interface MissionRecordRepositoryCustom { List findAllByMissionIdAndYearMonth(Long missionId, YearMonth yearMonth); + List findAllByCompletedMission(Long missionId); + List findFeedAll(List members); List findFeedAllByMemberId(Long memberId, List visibilities); diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java index 0fa3c802e..2679289bc 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -37,6 +37,15 @@ public List findAllByMissionIdAndYearMonth(Long missionId, YearMo .fetch(); } + @Override + public List findAllByCompletedMission(Long missionId) { + return jpaQueryFactory + .selectFrom(missionRecord) + .where(missionIdEq(missionId), uploadStatusCompleteEq()) + .orderBy(missionRecord.startedAt.asc()) + .fetch(); + } + @Override public boolean isCompletedMissionExistsToday(Long missionId) { LocalDate now = LocalDate.now(); @@ -119,4 +128,8 @@ private BooleanExpression monthEq(int month) { private BooleanExpression dayEq(int day) { return missionRecord.startedAt.dayOfMonth().eq(day); } + + private BooleanExpression uploadStatusCompleteEq() { + return missionRecord.uploadStatus.eq(ImageUploadStatus.COMPLETE); + } } diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/FocusMissionRecordItem.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/FocusMissionRecordItem.java new file mode 100644 index 000000000..3b234e99d --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/FocusMissionRecordItem.java @@ -0,0 +1,37 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record FocusMissionRecordItem( + @Schema(description = "번개 수", defaultValue = "3") long symbolStack, + @Schema(description = "미션 수행 시간 (Minute)", defaultValue = "34") long durationMinute, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2024-01-01 00:34:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-15 00:34:00", + type = "string") + LocalDateTime finishedAt) { + + public static FocusMissionRecordItem from(MissionRecord record) { + return new FocusMissionRecordItem( + record.getDuration().toMinutes() / 10, + record.getDuration().toMinutes(), + record.getStartedAt(), + record.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionStatisticsResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionStatisticsResponse.java new file mode 100644 index 000000000..c23ffad3d --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionStatisticsResponse.java @@ -0,0 +1,56 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +public record MissionStatisticsResponse( + @Schema(description = "수행 시간(시간)", defaultValue = "3") long totalMissionHour, + @Schema(description = "수행 시간(분)", defaultValue = "8") long totalMissionMinute, + @Schema(description = "번개 수", defaultValue = "8") long totalSymbolStack, + @Schema(description = "연속 성공일", defaultValue = "4") long continuousSuccessDay, + @Schema(description = "총 성공일", defaultValue = "9") long totalSuccessDay, + @Schema(description = "달성률", defaultValue = "20.4") double totalMissionAttainRate, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "집중 시작 시각", + defaultValue = "2024-01-01 00:34:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "집중 마친 시각", + defaultValue = "2024-01-15 00:34:00", + type = "string") + LocalDateTime finishedAt, + @Schema(description = "미션 수행 타임 테이블") List timeTable) { + + public static MissionStatisticsResponse of( + long totalMissionHour, + long totalMissionMinute, + long totalSymbolStack, + long continuousSuccessDay, + long totalSuccessDay, + double totalMissionAttainRate, + LocalDateTime startedAt, + LocalDateTime finishedAt, + List timeTable) { + return new MissionStatisticsResponse( + totalMissionHour, + totalMissionMinute, + totalSymbolStack, + continuousSuccessDay, + totalSuccessDay, + totalMissionAttainRate, + startedAt, + finishedAt, + timeTable); + } +} diff --git a/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java b/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java index 4d9e5f3d1..a59514e3c 100644 --- a/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java +++ b/src/test/java/com/depromeet/domain/missionRecord/application/MissionRecordServiceTest.java @@ -11,9 +11,13 @@ import com.depromeet.domain.mission.domain.Mission; import com.depromeet.domain.mission.domain.MissionCategory; import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; import com.depromeet.domain.missionRecord.dto.request.MissionRecordCreateRequest; +import com.depromeet.domain.missionRecord.dto.response.MissionStatisticsResponse; import com.depromeet.global.error.exception.CustomException; import com.depromeet.global.util.SecurityUtil; +import java.time.Duration; import java.time.LocalDateTime; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -31,12 +35,14 @@ class MissionRecordServiceTest { @Autowired MissionService missionService; @Autowired MemberRepository memberRepository; @Autowired MissionRepository missionRepository; + @Autowired MissionRecordRepository missionRecordRepository; @Autowired DatabaseCleaner databaseCleaner; @MockBean SecurityUtil securityUtil; private Member member; private Mission mission; - private LocalDateTime now = LocalDateTime.now(); + private final LocalDateTime now = LocalDateTime.now(); + private final LocalDateTime missionStartedAt = LocalDateTime.of(2024, 1, 24, 22, 58, 53); @BeforeEach void setUp() { @@ -47,7 +53,6 @@ void setUp() { Member.createNormalMember( OauthInfo.createOauthInfo("test", "test", "test"), "test"); memberRepository.save(member); - mission = Mission.createMission( "test", @@ -55,8 +60,8 @@ void setUp() { 1, MissionCategory.ETC, MissionVisibility.ALL, - now, - now.plusWeeks(2), + missionStartedAt, + missionStartedAt.plusWeeks(2), member); missionRepository.save(mission); } @@ -76,4 +81,52 @@ void setUp() { Assertions.assertThrows( CustomException.class, () -> missionRecordService.findOneMissionRecord(missionId)); } + + @Test + void 미션별_상세_통계_조회한다() { + // given + long durationMinute = 17; + String defaultImage = "https://image.10mm.today/default.png"; + LocalDateTime recordStartedAt = LocalDateTime.of(2024, 1, 25, 22, 58, 53); + LocalDateTime recordFinishedAt = recordStartedAt.plusMinutes(durationMinute); + + // 연속 성공 3일 + for (int i = 0; i < 3; i++) { + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + Duration.ofMinutes(durationMinute), + recordStartedAt.plusDays(i), + recordFinishedAt.plusDays(i), + mission); + missionRecord.updateUploadStatusPending(); + missionRecord.updateUploadStatusComplete("testRemark " + i, defaultImage); + missionRecordRepository.save(missionRecord); + } + + // 연속 성공 4일 + for (int i = 4; i < 8; i++) { + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + Duration.ofMinutes(durationMinute), + recordStartedAt.plusDays(i), + recordFinishedAt.plusDays(i), + mission); + missionRecord.updateUploadStatusPending(); + missionRecord.updateUploadStatusComplete("testRemark " + i, defaultImage); + missionRecordRepository.save(missionRecord); + } + + // when + MissionStatisticsResponse missionStatistics = + missionRecordService.findMissionStatistics(mission.getId()); + + // then + Assertions.assertEquals(missionStatistics.totalMissionHour(), 1); + Assertions.assertEquals(missionStatistics.totalMissionMinute(), 59); + Assertions.assertEquals(missionStatistics.totalSymbolStack(), 7); + Assertions.assertEquals(missionStatistics.continuousSuccessDay(), 4); + Assertions.assertEquals(missionStatistics.totalSuccessDay(), 7); + Assertions.assertEquals(missionStatistics.totalMissionAttainRate(), 46.7); + Assertions.assertEquals(missionStatistics.timeTable().size(), 7); + } }