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
@@ -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<BaseResponse<List<TimeBoxResponse>>> getTimeBoxes(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam(required = false) String date
) {
List<TimeBoxResponse> response = timeBoxService.getTimeBoxes(
userDetails.getUserId(),
date
);

return ResponseEntity.ok(
BaseResponse.onSuccess(TimeBoxSuccessCode.TIME_BOXES_RETRIEVED, response)
);
}
}
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<List<TimeBoxResponse>>> getTimeBoxes(
@Parameter(hidden = true) CustomUserDetails userDetails,
@Parameter(
description = "조회 날짜",
required = true,
example = "2026-07-01"
)
String date
);
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.Timo.Timo.domain.timebox.enums;

public enum TimeBoxAction {
START,
PAUSE,
RESUME,
COMPLETE
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
159 changes: 159 additions & 0 deletions src/main/java/com/Timo/Timo/domain/timebox/service/TimeBoxService.java
Original file line number Diff line number Diff line change
@@ -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<TimeBoxResponse> 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();
}
}
Loading