diff --git a/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java b/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java new file mode 100644 index 0000000..5649971 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/controller/TimeBoxController.java @@ -0,0 +1,45 @@ +package com.Timo.Timo.domain.timebox.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.Timo.Timo.domain.timebox.docs.TimeBoxControllerDocs; +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.domain.timebox.exception.TimeBoxSuccessCode; +import com.Timo.Timo.domain.timebox.service.TimeBoxService; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/time-boxes") +@RequiredArgsConstructor +@Tag(name = "Time Box", description = "타임박스 API") +public class TimeBoxController implements TimeBoxControllerDocs { + + private final TimeBoxService timeBoxService; + + @Override + @GetMapping + public ResponseEntity>> getTimeBoxes( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String date + ) { + List response = timeBoxService.getTimeBoxes( + userDetails.getUserId(), + date + ); + + return ResponseEntity.ok( + BaseResponse.onSuccess(TimeBoxSuccessCode.TIME_BOXES_RETRIEVED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java new file mode 100644 index 0000000..0e006a0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/docs/TimeBoxControllerDocs.java @@ -0,0 +1,74 @@ +package com.Timo.Timo.domain.timebox.docs; + +import java.util.List; + +import org.springframework.http.ResponseEntity; + +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.global.auth.principal.CustomUserDetails; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.Timo.Timo.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface TimeBoxControllerDocs { + + @Operation( + summary = "날짜별 타임박스 조회", + description = """ + 지정한 날짜의 타임라인에 표시할 타임박스 목록을 조회합니다. + 실제로 타이머가 작동한 TimerSession 단위로 반환하므로 일시정지 구간은 제외됩니다. + 타이머 재개 시 같은 timerId에 새로운 sessionId를 가진 타임박스가 생성됩니다. + startAction과 endAction으로 시작, 일시정지, 재개, 완료 시점을 구분합니다. + 사용자 종료와 예상시간 완료는 모두 COMPLETE 액션으로 반환합니다. + 완료 액션이 있는 마지막 타임박스에만 전체 실제 수행 시간을 분 단위로 반환합니다. + 진행 중인 구간은 endedAt과 endAction이 null로 반환됩니다. + 조회 결과가 없으면 빈 배열을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "타임박스 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "date 누락, 형식 오류 또는 유효하지 않은 날짜", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity>> getTimeBoxes( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter( + description = "조회 날짜", + required = true, + example = "2026-07-01" + ) + String date + ); +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java b/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java new file mode 100644 index 0000000..6c6e91f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/dto/response/TimeBoxResponse.java @@ -0,0 +1,53 @@ +package com.Timo.Timo.domain.timebox.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.Timo.Timo.domain.timebox.enums.TimeBoxAction; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TimeBoxResponse( + @Schema(description = "타임박스의 원본이 되는 타이머 세션 ID", example = "201") + Long sessionId, + + @Schema(description = "타이머 기록 ID", example = "101") + Long timerId, + + @Schema(description = "투두 ID", example = "12") + Long todoId, + + @Schema(description = "투두명", example = "밀린 집 정리") + String todoName, + + @Schema(description = "타임박스 표시 날짜", example = "2026-07-01") + LocalDate date, + + @Schema(description = "해당 날짜에 표시할 시작 일시", example = "2026-07-01T11:30:00") + LocalDateTime startedAt, + + @Schema( + description = "시작 일시의 액션. 이전 날짜부터 이어진 구간이면 null", + allowableValues = {"START", "RESUME"}, + nullable = true + ) + TimeBoxAction startAction, + + @Schema(description = "해당 날짜에 표시할 종료 일시. 진행 중이면 null", + example = "2026-07-01T13:30:00", nullable = true) + LocalDateTime endedAt, + + @Schema( + description = "종료 일시의 액션. 진행 중이거나 다음 날짜로 이어지면 null", + allowableValues = {"PAUSE", "COMPLETE"}, + nullable = true + ) + TimeBoxAction endAction, + + @Schema( + description = "전체 실제 수행 시간(분). 완료된 마지막 타임박스에만 포함", + example = "70", + nullable = true + ) + Integer actualMinutes +) {} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java b/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java new file mode 100644 index 0000000..035d4fb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/enums/TimeBoxAction.java @@ -0,0 +1,8 @@ +package com.Timo.Timo.domain.timebox.enums; + +public enum TimeBoxAction { + START, + PAUSE, + RESUME, + COMPLETE +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java new file mode 100644 index 0000000..487b664 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxErrorCode.java @@ -0,0 +1,21 @@ +package com.Timo.Timo.domain.timebox.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeBoxErrorCode implements BaseErrorCode { + + DATE_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON_400", "date는 필수입니다."), + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "COMMON_400", "date는 yyyy-MM-dd 형식이어야 합니다."), + INVALID_DATE(HttpStatus.BAD_REQUEST, "COMMON_400", "유효하지 않은 날짜입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java new file mode 100644 index 0000000..cab16ec --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/exception/TimeBoxSuccessCode.java @@ -0,0 +1,18 @@ +package com.Timo.Timo.domain.timebox.exception; + +import org.springframework.http.HttpStatus; + +import com.Timo.Timo.global.exception.code.BaseSuccessCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeBoxSuccessCode implements BaseSuccessCode { + + TIME_BOXES_RETRIEVED(HttpStatus.OK, "타임박스를 조회했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java b/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java new file mode 100644 index 0000000..2fd64d6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java @@ -0,0 +1,159 @@ +package com.Timo.Timo.domain.timebox.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.timebox.dto.response.TimeBoxResponse; +import com.Timo.Timo.domain.timebox.enums.TimeBoxAction; +import com.Timo.Timo.domain.timebox.support.TimeBoxDateParser; +import com.Timo.Timo.domain.timer.entity.TimerRecord; +import com.Timo.Timo.domain.timer.entity.TimerSession; +import com.Timo.Timo.domain.timer.repository.TimerSessionRepository; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimeBoxService { + + private static final int SECONDS_PER_MINUTE = 60; + + private final TimerSessionRepository timerSessionRepository; + private final UserRepository userRepository; + private final TimeBoxDateParser dateParser; + + public List getTimeBoxes(Long userId, String dateValue) { + LocalDate date = dateParser.parse(dateValue); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + ZoneId userZoneId = ZoneId.of(user.getZoneId()); + + LocalDateTime dayStart = date.atStartOfDay(); + LocalDateTime nextDayStart = date.plusDays(1).atStartOfDay(); + LocalDateTime fromInclusiveUtc = toUtc(dayStart, userZoneId); + LocalDateTime toExclusiveUtc = toUtc(nextDayStart, userZoneId); + LocalDateTime nowUtc = LocalDateTime.now(ZoneOffset.UTC); + + return timerSessionRepository.findTimeBoxSessions( + userId, + fromInclusiveUtc, + toExclusiveUtc, + nowUtc + ).stream() + .map(session -> toResponse(session, date, dayStart, nextDayStart, userZoneId, nowUtc)) + .toList(); + } + + private TimeBoxResponse toResponse( + TimerSession session, + LocalDate date, + LocalDateTime dayStart, + LocalDateTime nextDayStart, + ZoneId userZoneId, + LocalDateTime nowUtc + ) { + TimerRecord record = session.getTimerRecord(); + LocalDateTime localStartedAt = toUserTime(session.getStartedAt(), userZoneId); + LocalDateTime startedAt = localStartedAt.isBefore(dayStart) ? dayStart : localStartedAt; + LocalDateTime endedAt = resolveEndedAt(session, date, nextDayStart, userZoneId, nowUtc); + TimeBoxAction startAction = resolveStartAction(session, record, localStartedAt, dayStart, nextDayStart); + TimeBoxAction endAction = resolveEndAction(session, record, userZoneId, dayStart, nextDayStart); + + return new TimeBoxResponse( + session.getId(), + record.getId(), + record.getTodo().getId(), + record.getTodo().getTitle(), + date, + startedAt, + startAction, + endedAt, + endAction, + toActualMinutes(record, endAction) + ); + } + + private LocalDateTime resolveEndedAt( + TimerSession session, + LocalDate date, + LocalDateTime nextDayStart, + ZoneId userZoneId, + LocalDateTime nowUtc + ) { + if (session.getPausedAt() != null) { + LocalDateTime localEndedAt = toUserTime(session.getPausedAt(), userZoneId); + return localEndedAt.isAfter(nextDayStart) ? nextDayStart : localEndedAt; + } + + LocalDate today = toUserTime(nowUtc, userZoneId).toLocalDate(); + return date.isBefore(today) ? nextDayStart : null; + } + + private TimeBoxAction resolveStartAction( + TimerSession session, + TimerRecord record, + LocalDateTime localStartedAt, + LocalDateTime dayStart, + LocalDateTime nextDayStart + ) { + if (localStartedAt.isBefore(dayStart) || !localStartedAt.isBefore(nextDayStart)) { + return null; + } + return session.getStartedAt().equals(record.getStartedAt()) + ? TimeBoxAction.START + : TimeBoxAction.RESUME; + } + + private TimeBoxAction resolveEndAction( + TimerSession session, + TimerRecord record, + ZoneId userZoneId, + LocalDateTime dayStart, + LocalDateTime nextDayStart + ) { + if (session.getPausedAt() == null) { + return null; + } + + LocalDateTime localPausedAt = toUserTime(session.getPausedAt(), userZoneId); + if (localPausedAt.isBefore(dayStart) || localPausedAt.isAfter(nextDayStart)) { + return null; + } + + return session.getPausedAt().equals(record.getEndedAt()) + ? TimeBoxAction.COMPLETE + : TimeBoxAction.PAUSE; + } + + private Integer toActualMinutes(TimerRecord record, TimeBoxAction endAction) { + if (endAction != TimeBoxAction.COMPLETE || record.getActualSeconds() == null) { + return null; + } + return record.getActualSeconds() / SECONDS_PER_MINUTE; + } + + private LocalDateTime toUtc(LocalDateTime localDateTime, ZoneId userZoneId) { + return localDateTime + .atZone(userZoneId) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); + } + + private LocalDateTime toUserTime(LocalDateTime utcDateTime, ZoneId userZoneId) { + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(userZoneId) + .toLocalDateTime(); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java b/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java new file mode 100644 index 0000000..d069de9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/timebox/support/TimeBoxDateParser.java @@ -0,0 +1,34 @@ +package com.Timo.Timo.domain.timebox.support; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.timebox.exception.TimeBoxErrorCode; +import com.Timo.Timo.global.exception.CustomException; + +@Component +public class TimeBoxDateParser { + + private static final String DATE_PATTERN = "^\\d{4}-\\d{2}-\\d{2}$"; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); + + public LocalDate parse(String date) { + if (date == null || date.isBlank()) { + throw new CustomException(TimeBoxErrorCode.DATE_REQUIRED); + } + if (!date.matches(DATE_PATTERN)) { + throw new CustomException(TimeBoxErrorCode.INVALID_DATE_FORMAT); + } + + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException exception) { + throw new CustomException(TimeBoxErrorCode.INVALID_DATE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java index 96012e5..a00c18e 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.repository; import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -17,4 +18,21 @@ public interface TimerSessionRepository extends JpaRepository :fromInclusive + order by ts.startedAt asc, ts.id asc + """) + List findTimeBoxSessions( + @Param("userId") Long userId, + @Param("fromInclusive") LocalDateTime fromInclusive, + @Param("toExclusive") LocalDateTime toExclusive, + @Param("nowUtc") LocalDateTime nowUtc + ); }