From 45a2eb26deda951483b87e479f08be3874ef05e1 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Thu, 20 Aug 2026 15:39:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EC=9B=94=EA=B0=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=8B=9C=EA=B0=84=ED=98=95=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=ED=95=AD=EB=AA=A9=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ActionItemController.java | 33 ++++- .../docs/ActionItemControllerDocs.java | 26 ++++ .../dto/MonthlyTimedActionItemResponse.java | 58 +++++++++ .../repository/ActionItemsRepository.java | 31 +++++ .../action/service/ActionItemService.java | 100 +++++++++++++++ .../action/service/ActionItemServiceTest.java | 118 ++++++++++++++++++ 6 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java diff --git a/src/main/java/com/tryna/domain/action/controller/ActionItemController.java b/src/main/java/com/tryna/domain/action/controller/ActionItemController.java index 560e302..a39badb 100644 --- a/src/main/java/com/tryna/domain/action/controller/ActionItemController.java +++ b/src/main/java/com/tryna/domain/action/controller/ActionItemController.java @@ -181,4 +181,35 @@ public ResponseEntity> getTimedActionItems( ) ); } -} \ No newline at end of file + + /** + * F104-2: 월간 캘린더 내 시간형 실행 항목 조회 + */ + @GetMapping("/calendar/action-items/timed/monthly") + @Override + public ResponseEntity> getMonthlyTimedActionItems( + @RequestParam("year") Integer year, + @RequestParam("month") Integer month + ) { + // 1. SecurityContext에서 현재 사용자 ID 추출 + Long userId = extractUserIdFromSecurityContext(); + + // 2. 인증 정보가 없는 경우 인증 예외 처리 + if (userId == null) { + throw new BusinessException(AuthErrorCode.AUTH_401); + } + + // 3. 조회 연월의 시간형 실행 항목을 날짜별로 조회 + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, year, month); + + // 4. 공통 응답 형식으로 반환 + return ResponseEntity.ok( + ApiResponse.success( + "F104_ACTION_ITEM_200", + "월간 시간형 실행 항목 조회에 성공했습니다.", + response + ) + ); + } +} diff --git a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java index 5f9a2ad..4017de9 100644 --- a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java +++ b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java @@ -92,4 +92,30 @@ ResponseEntity> getTimedActionItems( ) @RequestParam("date") String date ); + + @Operation( + summary = "F104-2 월간 캘린더 내 시간형 실행 항목 조회", + description = """ + 조회 연월의 각 날짜에 표시할 시간형 실행 항목을 날짜별로 반환합니다. + 기존 일별 F104와 동일하게 현재 사용자의 일정에 연결된 TIMED_ACTION만 포함하며, + 반복 항목은 offsetDays와 회차별 완료 상태를 계산하여 반환합니다. + """, + operationId = "getMonthlyTimedActionItems" + ) + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> getMonthlyTimedActionItems( + @Parameter( + description = "조회할 연도(1970~2100)", + required = true, + example = "2026" + ) + @RequestParam("year") Integer year, + + @Parameter( + description = "조회할 월(1~12)", + required = true, + example = "8" + ) + @RequestParam("month") Integer month + ); } diff --git a/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java new file mode 100644 index 0000000..cb1374e --- /dev/null +++ b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java @@ -0,0 +1,58 @@ +package com.tryna.domain.action.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Schema(description = "F104-2 월간 캘린더 내 시간형 실행 항목 조회 응답 DTO") +public record MonthlyTimedActionItemResponse( + + @Schema(description = "조회 연도", example = "2026") + Integer year, + + @Schema(description = "조회 월", example = "8") + Integer month, + + @Schema(description = "날짜별 시간형 실행 항목 목록") + List days + +) { + + /** + * 월간 시간형 항목을 날짜별로 묶고 항목이 없는 날짜도 빈 목록으로 구성합니다. + */ + public static MonthlyTimedActionItemResponse of( + YearMonth yearMonth, + List items + ) { + Map> itemsByDate = items.stream() + .collect(Collectors.groupingBy(TimedActionItemResponse.Item::displayDate)); + + List days = yearMonth.atDay(1) + .datesUntil(yearMonth.atEndOfMonth().plusDays(1)) + .map(date -> new Day(date, itemsByDate.getOrDefault(date, List.of()))) + .toList(); + + return new MonthlyTimedActionItemResponse( + yearMonth.getYear(), + yearMonth.getMonthValue(), + days + ); + } + + @Schema(description = "날짜별 시간형 실행 항목") + public record Day( + + @Schema(description = "캘린더 날짜", example = "2026-08-20") + LocalDate date, + + @Schema(description = "해당 날짜에 표시할 시간형 실행 항목 목록") + List items + + ) { + } +} diff --git a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java index 982e762..03eeb92 100644 --- a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java +++ b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java @@ -172,6 +172,37 @@ List findCalendarActionItemsByDate( @Param("itemType") ItemType itemType ); + /** + * 현재 사용자의 일정에 연결된 시간형 실행 항목을 표시 날짜 범위로 조회합니다. + * + * 반복 일정의 offset 기반 항목은 서비스에서 월간 회차를 동적으로 계산하므로 + * 이 쿼리에서는 원본 displayDate를 직접 사용하는 항목만 조회합니다. + * + * @param userId 현재 사용자 ID + * @param startDate 조회 시작일 + * @param endDate 조회 종료일 + * @param itemType 항목 유형 + * @return 조회 범위에 직접 표시할 시간형 실행 항목 목록 + */ + @Query(""" + SELECT a + FROM ActionItems a + JOIN FETCH a.parentEvent e + JOIN UserEvents ue ON ue.event = e + WHERE ue.user.userId = :userId + AND a.displayDate BETWEEN :startDate AND :endDate + AND a.itemType = :itemType + AND (e.isRecurring = false OR a.offsetDays IS NULL) + AND a.deletedAt IS NULL + ORDER BY a.displayDate ASC, a.displayDatetime ASC, a.actionItemId ASC + """) + List findCalendarActionItemsByDateRange( + @Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("itemType") ItemType itemType + ); + /** * 반복 일정에 연결된 시간형 실행 항목 후보를 조회합니다. * diff --git a/src/main/java/com/tryna/domain/action/service/ActionItemService.java b/src/main/java/com/tryna/domain/action/service/ActionItemService.java index b5bfcdb..fb5f7ee 100644 --- a/src/main/java/com/tryna/domain/action/service/ActionItemService.java +++ b/src/main/java/com/tryna/domain/action/service/ActionItemService.java @@ -5,6 +5,7 @@ import com.tryna.domain.action.dto.ActionItemStatusUpdateRequest; import com.tryna.domain.action.dto.ActionItemStatusUpdateResponse; import com.tryna.domain.action.dto.EventActionItemResponse; +import com.tryna.domain.action.dto.MonthlyTimedActionItemResponse; import com.tryna.domain.action.dto.TimedActionItemResponse; import com.tryna.domain.action.entity.ActionItemOccurrenceStates; import com.tryna.domain.action.entity.ActionItems; @@ -32,6 +33,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -48,6 +50,9 @@ @Transactional(readOnly = true) public class ActionItemService { + private static final int MIN_YEAR = 1970; + private static final int MAX_YEAR = 2100; + private final ActionItemsRepository actionItemsRepository; private final ActionItemOccurrenceStatesRepository actionItemOccurrenceStatesRepository; private final RecommendationFeedbacksRepository recommendationFeedbacksRepository; @@ -490,6 +495,95 @@ public TimedActionItemResponse getTimedActionItems( return TimedActionItemResponse.of(date, sortedItems); } + /** + * 월간 캘린더에 표시할 시간형 실행 항목을 날짜별로 조회합니다. + * 기존 일별 F104와 동일한 반복 회차 및 회차별 상태 계산 규칙을 사용합니다. + */ + public MonthlyTimedActionItemResponse getMonthlyTimedActionItems( + Long userId, + Integer year, + Integer month + ) { + // 1. 조회 연월을 검증하고 월의 시작일과 종료일을 계산 + YearMonth yearMonth = parseYearMonth(year, month); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + + // 2. displayDate가 조회 월에 직접 포함되는 시간형 항목을 한 번에 조회 + List items = new ArrayList<>(); + List directActionItems = actionItemsRepository + .findCalendarActionItemsByDateRange( + userId, + startDate, + endDate, + ItemType.TIMED_ACTION + ); + + items.addAll(directActionItems.stream() + .map(TimedActionItemResponse.Item::from) + .toList()); + + // 3. 반복 원본은 한 번만 조회한 뒤 월의 각 날짜에 표시할 회차를 메모리에서 계산 + List recurringCandidates = actionItemsRepository + .findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + visibleActionItemEventStatuses() + ); + + List monthlyRecurringOccurrences = startDate + .datesUntil(endDate.plusDays(1)) + .flatMap(displayDate -> recurringCandidates.stream() + .map(actionItem -> toRecurringActionItemOccurrence(actionItem, displayDate)) + .filter(Objects::nonNull) + .map(occurrence -> new MonthlyRecurringActionItemOccurrence( + displayDate, + occurrence + ))) + .toList(); + + // 4. 발생한 회차의 완료 상태를 일괄 조회하여 각 월간 항목에 반영 + List recurringOccurrences = monthlyRecurringOccurrences.stream() + .map(MonthlyRecurringActionItemOccurrence::occurrence) + .toList(); + Map statesByKey = findStatesByKey(recurringOccurrences); + + items.addAll(monthlyRecurringOccurrences.stream() + .map(monthlyOccurrence -> TimedActionItemResponse.Item.fromOccurrence( + monthlyOccurrence.occurrence().actionItem(), + monthlyOccurrence.occurrence().occurrenceDate(), + monthlyOccurrence.displayDate(), + monthlyOccurrence.occurrence().displayTime(), + statesByKey.get(new StateKey( + monthlyOccurrence.occurrence().actionItem().getActionItemId(), + monthlyOccurrence.occurrence().occurrenceDate() + )) + )) + .toList()); + + // 5. 날짜와 시간 순으로 정렬한 뒤 모든 날짜를 포함하는 월간 응답으로 변환 + List sortedItems = items.stream() + .sorted(Comparator + .comparing(TimedActionItemResponse.Item::displayDate) + .thenComparing( + TimedActionItemResponse.Item::displayTime, + Comparator.nullsLast(Comparator.naturalOrder()) + ) + .thenComparing(TimedActionItemResponse.Item::actionItemId)) + .toList(); + + return MonthlyTimedActionItemResponse.of(yearMonth, sortedItems); + } + + private YearMonth parseYearMonth(Integer year, Integer month) { + if (year == null || year < MIN_YEAR || year > MAX_YEAR + || month == null || month < 1 || month > 12) { + throw new BusinessException(ActionErrorCode.F104_ACTION_ITEM_400); + } + + return YearMonth.of(year, month); + } + private List visibleActionItemEventStatuses() { return List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION); } @@ -591,6 +685,12 @@ private record RecurringActionItemOccurrence( ) { } + private record MonthlyRecurringActionItemOccurrence( + LocalDate displayDate, + RecurringActionItemOccurrence occurrence + ) { + } + private record StateKey( Long actionItemId, LocalDate occurrenceDate diff --git a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java index ac1d030..d2bda46 100644 --- a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java +++ b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java @@ -10,13 +10,16 @@ import com.tryna.domain.action.dto.ActionItemStatusUpdateRequest; import com.tryna.domain.action.dto.ActionItemStatusUpdateResponse; import com.tryna.domain.action.dto.EventActionItemResponse; +import com.tryna.domain.action.dto.MonthlyTimedActionItemResponse; import com.tryna.domain.action.entity.ActionItemOccurrenceStates; import com.tryna.domain.action.entity.ActionItems; import com.tryna.domain.action.enums.ActionItemStatus; +import com.tryna.domain.action.enums.CreatedBy; import com.tryna.domain.action.enums.ItemType; import com.tryna.domain.action.repository.ActionItemOccurrenceStatesRepository; import com.tryna.domain.action.repository.ActionItemsRepository; import com.tryna.domain.event.entity.Events; +import com.tryna.domain.event.enums.EventStatus; import com.tryna.domain.event.enums.RecurrenceDayOfWeek; import com.tryna.domain.event.enums.RecurrenceType; import com.tryna.domain.event.repository.EventsRepository; @@ -27,6 +30,7 @@ import com.tryna.global.exception.ActionErrorCode; import com.tryna.global.exception.BusinessException; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -226,6 +230,120 @@ void recurringMultiDayStatusUpdateRejectsDateOutsideOccurrence() { ); } + @Test + void monthlyTimedActionItemsIncludeDirectItemsOnDisplayDate() { + Long userId = 1L; + ActionItems actionItem = mock(ActionItems.class); + Events event = mock(Events.class); + LocalDate displayDate = LocalDate.of(2026, 8, 20); + + when(actionItem.getActionItemId()).thenReturn(200L); + when(actionItem.getParentEvent()).thenReturn(event); + when(actionItem.getTitle()).thenReturn("선물 구매하기"); + when(actionItem.getItemType()).thenReturn(ItemType.TIMED_ACTION); + when(actionItem.getDisplayDate()).thenReturn(displayDate); + when(actionItem.getDisplayDatetime()).thenReturn(displayDate.atTime(18, 0)); + when(actionItem.getActionItemStatus()).thenReturn(ActionItemStatus.PENDING); + when(actionItem.getCreatedBy()).thenReturn(CreatedBy.USER); + when(event.getEventId()).thenReturn(10L); + when(event.getTitle()).thenReturn("지민이 생일"); + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION + )).thenReturn(List.of(actionItem)); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(8); + assertThat(response.days()).hasSize(31); + MonthlyTimedActionItemResponse.Day targetDay = response.days().get(19); + assertThat(targetDay.date()).isEqualTo(displayDate); + assertThat(targetDay.items()).hasSize(1); + assertThat(targetDay.items().getFirst().title()).isEqualTo("선물 구매하기"); + } + + @Test + void monthlyTimedActionItemsCalculateRecurringOccurrencesAndApplyOccurrenceState() { + Long userId = 1L; + Long actionItemId = 300L; + Events event = mock(Events.class); + ActionItems actionItem = mock(ActionItems.class); + ActionItemOccurrenceStates state = mock(ActionItemOccurrenceStates.class); + LocalDate completedOccurrenceDate = LocalDate.of(2026, 8, 10); + + when(event.getEventId()).thenReturn(20L); + when(event.getTitle()).thenReturn("매주 운동"); + when(event.getIsRecurring()).thenReturn(true); + when(event.getStartDate()).thenReturn(LocalDate.of(2026, 8, 3)); + when(event.getRecurrenceType()).thenReturn(RecurrenceType.WEEKLY); + when(event.getRecurrenceInterval()).thenReturn(1); + when(event.getRecurrenceDayOfWeek()).thenReturn(RecurrenceDayOfWeek.MON); + when(actionItem.getActionItemId()).thenReturn(actionItemId); + when(actionItem.getParentEvent()).thenReturn(event); + when(actionItem.getTitle()).thenReturn("운동복 준비하기"); + when(actionItem.getItemType()).thenReturn(ItemType.TIMED_ACTION); + when(actionItem.getOffsetDays()).thenReturn(-1); + when(actionItem.getDisplayDatetime()).thenReturn(LocalDateTime.of(2026, 8, 2, 8, 0)); + when(actionItem.getCreatedBy()).thenReturn(CreatedBy.SYSTEM); + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION + )).thenReturn(List.of()); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of(actionItem)); + when(actionItemOccurrenceStatesRepository + .findByActionItem_ActionItemIdInAndOccurrenceDateIn( + List.of(actionItemId), + List.of( + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 10), + LocalDate.of(2026, 8, 17), + LocalDate.of(2026, 8, 24), + LocalDate.of(2026, 8, 31) + ) + )).thenReturn(List.of(state)); + when(state.getActionItem()).thenReturn(actionItem); + when(state.getOccurrenceDate()).thenReturn(completedOccurrenceDate); + when(state.getActionItemStatus()).thenReturn(ActionItemStatus.COMPLETED); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.days().stream() + .flatMap(day -> day.items().stream())) + .hasSize(5); + assertThat(response.days().get(8).items().getFirst().occurrenceDate()) + .isEqualTo(completedOccurrenceDate); + assertThat(response.days().get(8).items().getFirst().displayDate()) + .isEqualTo(LocalDate.of(2026, 8, 9)); + assertThat(response.days().get(8).items().getFirst().actionItemStatus()) + .isEqualTo(ActionItemStatus.COMPLETED); + assertThat(response.days().get(15).items().getFirst().actionItemStatus()) + .isEqualTo(ActionItemStatus.PENDING); + } + + @Test + void monthlyTimedActionItemsRejectInvalidYearMonth() { + assertThatThrownBy(() -> actionItemService.getMonthlyTimedActionItems(1L, 2026, 13)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ActionErrorCode.F104_ACTION_ITEM_400) + ); + } + private Events recurringTwoDayWeeklyEvent() { Events event = mock(Events.class); when(event.getIsRecurring()).thenReturn(true); From 8f80ba999c88aada9384ea53a2a05685955c8186 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Thu, 20 Aug 2026 16:31:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FEAT]=20=EC=9B=94=EA=B0=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=98=95=20=ED=95=AD=EB=AA=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/ActionItemControllerDocs.java | 9 +- .../dto/MonthlyTimedActionItemResponse.java | 93 +++++++++----- .../repository/ActionItemsRepository.java | 4 +- .../action/service/ActionItemService.java | 113 ++++++++++++++---- .../action/service/ActionItemServiceTest.java | 85 +++++++++---- 5 files changed, 226 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java index 4017de9..b4159c6 100644 --- a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java +++ b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java @@ -96,9 +96,12 @@ ResponseEntity> getTimedActionItems( @Operation( summary = "F104-2 월간 캘린더 내 시간형 실행 항목 조회", description = """ - 조회 연월의 각 날짜에 표시할 시간형 실행 항목을 날짜별로 반환합니다. - 기존 일별 F104와 동일하게 현재 사용자의 일정에 연결된 TIMED_ACTION만 포함하며, - 반복 항목은 offsetDays와 회차별 완료 상태를 계산하여 반환합니다. + 조회 연월에 displayDate가 포함되는 시간형 실행 항목을 반환합니다. + 부모 일정 날짜와 관계없이 계산된 displayDate를 기준으로 월 포함 여부를 판단하며, + 현재 사용자의 CONFIRMED 또는 NEEDS_CONFIRMATION 일정에 연결된 TIMED_ACTION만 포함합니다. + 삭제된 항목과 삭제된 반복 회차는 제외하고, 반복 항목은 offsetDays와 회차별 상태를 반영합니다. + 각 항목에는 부모 일정의 labelId와 상세 이동에 사용할 parentOccurrenceDate가 포함됩니다. + 회원과 비회원 모두 유효한 Bearer 토큰으로 호출할 수 있습니다. """, operationId = "getMonthlyTimedActionItems" ) diff --git a/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java index cb1374e..6d31238 100644 --- a/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java +++ b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java @@ -1,12 +1,12 @@ package com.tryna.domain.action.dto; +import com.tryna.domain.action.enums.ActionItemStatus; +import com.tryna.domain.action.enums.CreatedBy; +import com.tryna.domain.action.enums.ItemType; import io.swagger.v3.oas.annotations.media.Schema; - import java.time.LocalDate; -import java.time.YearMonth; +import java.time.LocalDateTime; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Schema(description = "F104-2 월간 캘린더 내 시간형 실행 항목 조회 응답 DTO") public record MonthlyTimedActionItemResponse( @@ -17,42 +17,69 @@ public record MonthlyTimedActionItemResponse( @Schema(description = "조회 월", example = "8") Integer month, - @Schema(description = "날짜별 시간형 실행 항목 목록") - List days + @Schema(description = "조회 월에 표시할 시간형 실행 항목 목록") + List items ) { + @Schema(description = "월간 캘린더에 표시할 시간형 실행 항목") + public record Item( - /** - * 월간 시간형 항목을 날짜별로 묶고 항목이 없는 날짜도 빈 목록으로 구성합니다. - */ - public static MonthlyTimedActionItemResponse of( - YearMonth yearMonth, - List items - ) { - Map> itemsByDate = items.stream() - .collect(Collectors.groupingBy(TimedActionItemResponse.Item::displayDate)); - - List days = yearMonth.atDay(1) - .datesUntil(yearMonth.atEndOfMonth().plusDays(1)) - .map(date -> new Day(date, itemsByDate.getOrDefault(date, List.of()))) - .toList(); - - return new MonthlyTimedActionItemResponse( - yearMonth.getYear(), - yearMonth.getMonthValue(), - days - ); - } + @Schema(description = "준비/실행 항목 ID", example = "10") + Long actionItemId, + + @Schema(description = "연결된 일정 ID", example = "1") + Long parentEventId, + + @Schema(description = "항목이 속한 부모 일정 회차 날짜", example = "2026-09-02") + LocalDate parentOccurrenceDate, + + @Schema(description = "연결된 일정 제목", example = "엄마 생신") + String parentEventTitle, + + @Schema(description = "현재 사용자의 부모 일정 라벨 ID", example = "110") + Long labelId, + + @Schema(description = "준비/실행 항목 제목", example = "꽃다발 준비하기") + String title, + + @Schema(description = "항목 유형", example = "TIMED_ACTION") + ItemType itemType, + + @Schema(description = "캘린더 표시 날짜", example = "2026-08-30") + LocalDate displayDate, + + @Schema(description = "캘린더 표시 시간", example = "2026-08-30T09:00:00") + LocalDateTime displayTime, - @Schema(description = "날짜별 시간형 실행 항목") - public record Day( + @Schema(description = "항목 상태", example = "PENDING") + ActionItemStatus actionItemStatus, - @Schema(description = "캘린더 날짜", example = "2026-08-20") - LocalDate date, + @Schema(description = "항목 생성 주체", example = "SYSTEM") + CreatedBy createdBy, - @Schema(description = "해당 날짜에 표시할 시간형 실행 항목 목록") - List items + @Schema(description = "완료 처리 일시. 미완료 상태이면 null", example = "2026-08-30T18:30:12") + LocalDateTime completedAt ) { + public static Item from( + TimedActionItemResponse.Item item, + LocalDate parentOccurrenceDate, + Long labelId + ) { + return new Item( + item.actionItemId(), + item.parentEventId(), + parentOccurrenceDate, + item.parentEventTitle(), + labelId, + item.title(), + item.itemType(), + item.displayDate(), + item.displayTime(), + item.actionItemStatus(), + item.createdBy(), + item.completedAt() + ); + } } } diff --git a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java index 03eeb92..83f191a 100644 --- a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java +++ b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java @@ -192,6 +192,7 @@ List findCalendarActionItemsByDate( WHERE ue.user.userId = :userId AND a.displayDate BETWEEN :startDate AND :endDate AND a.itemType = :itemType + AND e.eventStatus IN :eventStatuses AND (e.isRecurring = false OR a.offsetDays IS NULL) AND a.deletedAt IS NULL ORDER BY a.displayDate ASC, a.displayDatetime ASC, a.actionItemId ASC @@ -200,7 +201,8 @@ List findCalendarActionItemsByDateRange( @Param("userId") Long userId, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, - @Param("itemType") ItemType itemType + @Param("itemType") ItemType itemType, + @Param("eventStatuses") Collection eventStatuses ); /** diff --git a/src/main/java/com/tryna/domain/action/service/ActionItemService.java b/src/main/java/com/tryna/domain/action/service/ActionItemService.java index fb5f7ee..4f3cc46 100644 --- a/src/main/java/com/tryna/domain/action/service/ActionItemService.java +++ b/src/main/java/com/tryna/domain/action/service/ActionItemService.java @@ -510,17 +510,26 @@ public MonthlyTimedActionItemResponse getMonthlyTimedActionItems( LocalDate endDate = yearMonth.atEndOfMonth(); // 2. displayDate가 조회 월에 직접 포함되는 시간형 항목을 한 번에 조회 - List items = new ArrayList<>(); + List candidates = new ArrayList<>(); List directActionItems = actionItemsRepository .findCalendarActionItemsByDateRange( userId, startDate, endDate, - ItemType.TIMED_ACTION + ItemType.TIMED_ACTION, + visibleActionItemEventStatuses() ); - items.addAll(directActionItems.stream() - .map(TimedActionItemResponse.Item::from) + candidates.addAll(directActionItems.stream() + .filter(actionItem -> actionItem.getActionItemStatus() != ActionItemStatus.DELETED) + .map(actionItem -> new MonthlyActionItemCandidate( + TimedActionItemResponse.Item.from(actionItem), + resolveParentOccurrenceDate( + actionItem, + actionItem.getOccurrenceDate(), + actionItem.getDisplayDate() + ) + )) .toList()); // 3. 반복 원본은 한 번만 조회한 뒤 월의 각 날짜에 표시할 회차를 메모리에서 계산 @@ -529,7 +538,10 @@ public MonthlyTimedActionItemResponse getMonthlyTimedActionItems( userId, ItemType.TIMED_ACTION, visibleActionItemEventStatuses() - ); + ) + .stream() + .filter(actionItem -> actionItem.getActionItemStatus() != ActionItemStatus.DELETED) + .toList(); List monthlyRecurringOccurrences = startDate .datesUntil(endDate.plusDays(1)) @@ -548,31 +560,86 @@ public MonthlyTimedActionItemResponse getMonthlyTimedActionItems( .toList(); Map statesByKey = findStatesByKey(recurringOccurrences); - items.addAll(monthlyRecurringOccurrences.stream() - .map(monthlyOccurrence -> TimedActionItemResponse.Item.fromOccurrence( - monthlyOccurrence.occurrence().actionItem(), - monthlyOccurrence.occurrence().occurrenceDate(), - monthlyOccurrence.displayDate(), - monthlyOccurrence.occurrence().displayTime(), - statesByKey.get(new StateKey( + candidates.addAll(monthlyRecurringOccurrences.stream() + .filter(monthlyOccurrence -> { + ActionItemOccurrenceStates state = statesByKey.get(new StateKey( + monthlyOccurrence.occurrence().actionItem().getActionItemId(), + monthlyOccurrence.occurrence().occurrenceDate() + )); + return state == null || state.getActionItemStatus() != ActionItemStatus.DELETED; + }) + .map(monthlyOccurrence -> { + ActionItemOccurrenceStates state = statesByKey.get(new StateKey( monthlyOccurrence.occurrence().actionItem().getActionItemId(), monthlyOccurrence.occurrence().occurrenceDate() - )) - )) + )); + TimedActionItemResponse.Item item = TimedActionItemResponse.Item.fromOccurrence( + monthlyOccurrence.occurrence().actionItem(), + monthlyOccurrence.occurrence().occurrenceDate(), + monthlyOccurrence.displayDate(), + monthlyOccurrence.occurrence().displayTime(), + state + ); + return new MonthlyActionItemCandidate( + item, + monthlyOccurrence.occurrence().occurrenceDate() + ); + }) .toList()); - // 5. 날짜와 시간 순으로 정렬한 뒤 모든 날짜를 포함하는 월간 응답으로 변환 - List sortedItems = items.stream() + // 5. 부모 일정 라벨을 일괄 조회한 뒤 월간 응답 항목으로 변환 + List parentEventIds = candidates.stream() + .map(candidate -> candidate.item().parentEventId()) + .distinct() + .toList(); + Map labelIdsByEventId = findLabelIdsByEventId(userId, parentEventIds); + + List sortedItems = candidates.stream() + .map(candidate -> MonthlyTimedActionItemResponse.Item.from( + candidate.item(), + candidate.parentOccurrenceDate(), + labelIdsByEventId.get(candidate.item().parentEventId()) + )) .sorted(Comparator - .comparing(TimedActionItemResponse.Item::displayDate) + .comparing(MonthlyTimedActionItemResponse.Item::displayDate) .thenComparing( - TimedActionItemResponse.Item::displayTime, + MonthlyTimedActionItemResponse.Item::displayTime, Comparator.nullsLast(Comparator.naturalOrder()) ) - .thenComparing(TimedActionItemResponse.Item::actionItemId)) + .thenComparing(MonthlyTimedActionItemResponse.Item::actionItemId)) .toList(); - return MonthlyTimedActionItemResponse.of(yearMonth, sortedItems); + return new MonthlyTimedActionItemResponse( + yearMonth.getYear(), + yearMonth.getMonthValue(), + sortedItems + ); + } + + private Map findLabelIdsByEventId(Long userId, List eventIds) { + if (eventIds.isEmpty()) { + return Map.of(); + } + + return userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, eventIds) + .stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } + + private LocalDate resolveParentOccurrenceDate( + ActionItems actionItem, + LocalDate occurrenceDate, + LocalDate displayDate + ) { + if (occurrenceDate != null) { + return occurrenceDate; + } + + LocalDate parentStartDate = actionItem.getParentEvent().getStartDate(); + return parentStartDate != null ? parentStartDate : displayDate; } private YearMonth parseYearMonth(Integer year, Integer month) { @@ -691,6 +758,12 @@ private record MonthlyRecurringActionItemOccurrence( ) { } + private record MonthlyActionItemCandidate( + TimedActionItemResponse.Item item, + LocalDate parentOccurrenceDate + ) { + } + private record StateKey( Long actionItemId, LocalDate occurrenceDate diff --git a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java index d2bda46..92fdd3c 100644 --- a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java +++ b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java @@ -234,8 +234,10 @@ void recurringMultiDayStatusUpdateRejectsDateOutsideOccurrence() { void monthlyTimedActionItemsIncludeDirectItemsOnDisplayDate() { Long userId = 1L; ActionItems actionItem = mock(ActionItems.class); + ActionItems deletedActionItem = mock(ActionItems.class); Events event = mock(Events.class); - LocalDate displayDate = LocalDate.of(2026, 8, 20); + LocalDate displayDate = LocalDate.of(2026, 8, 30); + LocalDate parentStartDate = LocalDate.of(2026, 9, 2); when(actionItem.getActionItemId()).thenReturn(200L); when(actionItem.getParentEvent()).thenReturn(event); @@ -245,30 +247,35 @@ void monthlyTimedActionItemsIncludeDirectItemsOnDisplayDate() { when(actionItem.getDisplayDatetime()).thenReturn(displayDate.atTime(18, 0)); when(actionItem.getActionItemStatus()).thenReturn(ActionItemStatus.PENDING); when(actionItem.getCreatedBy()).thenReturn(CreatedBy.USER); + when(deletedActionItem.getActionItemStatus()).thenReturn(ActionItemStatus.DELETED); when(event.getEventId()).thenReturn(10L); when(event.getTitle()).thenReturn("지민이 생일"); + when(event.getStartDate()).thenReturn(parentStartDate); when(actionItemsRepository.findCalendarActionItemsByDateRange( userId, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), - ItemType.TIMED_ACTION - )).thenReturn(List.of(actionItem)); + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of(actionItem, deletedActionItem)); when(actionItemsRepository.findRecurringTimedActionItemsByUserId( userId, ItemType.TIMED_ACTION, List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) )).thenReturn(List.of()); + when(userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, List.of(10L))) + .thenReturn(List.of(new Object[]{10L, 110L})); MonthlyTimedActionItemResponse response = actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); assertThat(response.year()).isEqualTo(2026); assertThat(response.month()).isEqualTo(8); - assertThat(response.days()).hasSize(31); - MonthlyTimedActionItemResponse.Day targetDay = response.days().get(19); - assertThat(targetDay.date()).isEqualTo(displayDate); - assertThat(targetDay.items()).hasSize(1); - assertThat(targetDay.items().getFirst().title()).isEqualTo("선물 구매하기"); + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().title()).isEqualTo("선물 구매하기"); + assertThat(response.items().getFirst().displayDate()).isEqualTo(displayDate); + assertThat(response.items().getFirst().parentOccurrenceDate()).isEqualTo(parentStartDate); + assertThat(response.items().getFirst().labelId()).isEqualTo(110L); } @Test @@ -278,7 +285,9 @@ void monthlyTimedActionItemsCalculateRecurringOccurrencesAndApplyOccurrenceState Events event = mock(Events.class); ActionItems actionItem = mock(ActionItems.class); ActionItemOccurrenceStates state = mock(ActionItemOccurrenceStates.class); + ActionItemOccurrenceStates deletedState = mock(ActionItemOccurrenceStates.class); LocalDate completedOccurrenceDate = LocalDate.of(2026, 8, 10); + LocalDate deletedOccurrenceDate = LocalDate.of(2026, 8, 17); when(event.getEventId()).thenReturn(20L); when(event.getTitle()).thenReturn("매주 운동"); @@ -298,7 +307,8 @@ void monthlyTimedActionItemsCalculateRecurringOccurrencesAndApplyOccurrenceState userId, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), - ItemType.TIMED_ACTION + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) )).thenReturn(List.of()); when(actionItemsRepository.findRecurringTimedActionItemsByUserId( userId, @@ -315,25 +325,58 @@ void monthlyTimedActionItemsCalculateRecurringOccurrencesAndApplyOccurrenceState LocalDate.of(2026, 8, 24), LocalDate.of(2026, 8, 31) ) - )).thenReturn(List.of(state)); + )).thenReturn(List.of(state, deletedState)); when(state.getActionItem()).thenReturn(actionItem); when(state.getOccurrenceDate()).thenReturn(completedOccurrenceDate); when(state.getActionItemStatus()).thenReturn(ActionItemStatus.COMPLETED); + when(deletedState.getActionItem()).thenReturn(actionItem); + when(deletedState.getOccurrenceDate()).thenReturn(deletedOccurrenceDate); + when(deletedState.getActionItemStatus()).thenReturn(ActionItemStatus.DELETED); + when(userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, List.of(20L))) + .thenReturn(List.of(new Object[]{20L, 120L})); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.items()).hasSize(4); + assertThat(response.items()) + .noneMatch(item -> item.parentOccurrenceDate().equals(deletedOccurrenceDate)); + assertThat(response.items()) + .filteredOn(item -> item.parentOccurrenceDate().equals(completedOccurrenceDate)) + .singleElement() + .satisfies(item -> { + assertThat(item.displayDate()).isEqualTo(LocalDate.of(2026, 8, 9)); + assertThat(item.actionItemStatus()).isEqualTo(ActionItemStatus.COMPLETED); + assertThat(item.labelId()).isEqualTo(120L); + }); + assertThat(response.items()) + .filteredOn(item -> item.parentOccurrenceDate().equals(LocalDate.of(2026, 8, 24))) + .singleElement() + .satisfies(item -> assertThat(item.actionItemStatus()).isEqualTo(ActionItemStatus.PENDING)); + } + + @Test + void monthlyTimedActionItemsReturnEmptyItemsWhenNoMatches() { + Long userId = 1L; + + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); MonthlyTimedActionItemResponse response = actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); - assertThat(response.days().stream() - .flatMap(day -> day.items().stream())) - .hasSize(5); - assertThat(response.days().get(8).items().getFirst().occurrenceDate()) - .isEqualTo(completedOccurrenceDate); - assertThat(response.days().get(8).items().getFirst().displayDate()) - .isEqualTo(LocalDate.of(2026, 8, 9)); - assertThat(response.days().get(8).items().getFirst().actionItemStatus()) - .isEqualTo(ActionItemStatus.COMPLETED); - assertThat(response.days().get(15).items().getFirst().actionItemStatus()) - .isEqualTo(ActionItemStatus.PENDING); + assertThat(response.items()).isEmpty(); + verify(userEventsRepository, never()).findLabelIdsByUserIdAndEventIds(userId, List.of()); } @Test