Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import com.Timo.Timo.global.response.BaseResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand All @@ -28,6 +30,7 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -45,11 +48,13 @@ public class TimerController implements TimerStartControllerDocs, TimerStatusCon
@PostMapping("/todos/{todoId}/timers/start")
public ResponseEntity<BaseResponse<TimerStartResponse>> startTimer(
@PathVariable Long todoId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@AuthenticationPrincipal CustomUserDetails userDetails
) {

Long userId = userDetails.getUserId();
TimerStartResponse response = timerService.startTimer(userId, todoId);
TimerStartResponse response = timerService.startTimer(userId, todoId, date);

return ResponseEntity.status(HttpStatus.CREATED)
.body(BaseResponse.onSuccess(TimerSuccessCode.TIMER_STARTED, response));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

public interface TimerStartControllerDocs {

Expand All @@ -21,8 +24,8 @@ public interface TimerStartControllerDocs {
투두의 타이머를 시작합니다.
한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다.

응답의 date는 타이머가 귀속되는 날짜(사용자 시간대 기준 시작일)입니다.
반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다.
쿼리 파라미터 date로 타이머가 상태를 반영할 대상 날짜를 지정할 수 있으며(미래/과거 포함), 미지정 시 사용자 타임존 기준 오늘로 처리됩니다. 이후 일시정지/재개/완료/중지는 이 날짜를 그대로 사용합니다.
"""
)
@ApiResponses({
Expand Down Expand Up @@ -75,6 +78,9 @@ public interface TimerStartControllerDocs {
ResponseEntity<BaseResponse<TimerStartResponse>> startTimer(
@Parameter(description = "타이머를 시작할 투두 ID", example = "3")
@PathVariable Long todoId,
@Parameter(description = "타이머가 상태를 반영할 대상 날짜(미지정 시 오늘). ISO 형식", example = "2026-07-20")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@Parameter(hidden = true) CustomUserDetails userDetails
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public class TimerRecord {
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;

@Column(name = "target_date")
private LocalDate targetDate;

@Column(name = "ended_at")
private LocalDateTime endedAt;

Expand All @@ -75,11 +78,13 @@ public class TimerRecord {
private LocalDateTime createdAt;

@Builder
private TimerRecord(User user, Todo todo, Integer plannedSeconds, LocalDateTime startedAt) {
private TimerRecord(User user, Todo todo, Integer plannedSeconds, LocalDateTime startedAt,
LocalDate targetDate) {
this.user = user;
this.todo = todo;
this.plannedSeconds = plannedSeconds;
this.startedAt = startedAt;
this.targetDate = targetDate;
this.extendedSeconds = 0;
this.status = TimerStatus.RUNNING;
}
Expand Down Expand Up @@ -131,6 +136,9 @@ public boolean isFinished() {
}

public LocalDate getTimerDate() {
if (targetDate != null) {
return targetDate;
}
ZoneId userZone = ZoneId.of(user.getZoneId());
return startedAt.atZone(ZoneOffset.UTC).withZoneSameInstant(userZone).toLocalDate();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -51,7 +52,7 @@ public class TimerService {
private final PlatformTransactionManager transactionManager;

@Transactional
public TimerStartResponse startTimer(Long userId, Long todoId) {
public TimerStartResponse startTimer(Long userId, Long todoId, LocalDate targetDate) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
Todo todo = todoRepository.findById(todoId)
Expand All @@ -72,11 +73,16 @@ public TimerStartResponse startTimer(Long userId, Long todoId) {

LocalDateTime now = LocalDateTime.now();

LocalDate resolvedDate = targetDate != null
? targetDate
: LocalDate.now(ZoneId.of(user.getZoneId()));

TimerRecord timerRecord = TimerRecord.builder()
.user(user)
.todo(todo)
.plannedSeconds(todo.getDurationSeconds())
.startedAt(now)
.targetDate(resolvedDate)
.build();
timerRecordRepository.save(timerRecord);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,39 +78,55 @@ public TodoInstance applyReorder(Long userId, Todo targetRule, LocalDate date, I
private TodoInstance requireTarget(Map<Long, TodoInstance> instancesByTodoId, Todo targetRule) {
TodoInstance target = instancesByTodoId.get(targetRule.getId());
if (target == null) {
// 해당 날짜에 발생하지 않는 규칙이면 그룹에 인스턴스가 없다.
throw new CustomException(TodoErrorCode.TODO_NOT_FOUND);
}
return target;
}

private Map<Long, TodoInstance> materializeDayGroup(Long userId, LocalDate date) {
List<Todo> occurringRules = todoRepository.findRulesInRange(userId, date, date).stream()
.filter(rule -> todoDateCalculator.occursOn(rule, date))
.toList();

List<Long> todoIds = occurringRules.stream()
.map(Todo::getId)
.toList();

Map<Long, TodoInstance> existing = todoIds.isEmpty()
? Map.of()
: todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, date, date).stream()
.collect(Collectors.toMap(
instance -> instance.getTodo().getId(),
Function.identity()
));

Map<Long, TodoInstance> result = new LinkedHashMap<>();
for (int index = 0; index < occurringRules.size(); index++) {
Todo rule = occurringRules.get(index);
TodoInstance instance = existing.get(rule.getId());
if (instance == null) {
instance = todoInstanceRepository.save(TodoInstance.of(rule, date, index));
}
result.put(rule.getId(), instance);
}
return result;
Map<Long, TodoInstance> result = new LinkedHashMap<>();

List<TodoInstance> existingInstances = todoInstanceRepository.findDailyInstances(userId, date);
for (TodoInstance instance : existingInstances) {
result.put(instance.getTodo().getId(), instance);
}

List<Todo> occurringRules = todoRepository.findRulesInRange(userId, date, date).stream()
.filter(rule -> todoDateCalculator.occursOn(rule, date))
.toList();

List<TodoInstance> newInstances = occurringRules.stream()
.filter(rule -> !result.containsKey(rule.getId()))
.sorted(Comparator.comparing(Todo::getCreatedAt).thenComparing(Todo::getId).reversed())
.map(rule -> todoInstanceRepository.save(TodoInstance.of(rule, date, 0)))
.toList();

if (newInstances.isEmpty()) {
return result;
}

List<TodoInstance> existingIncomplete = existingInstances.stream()
.filter(instance -> !instance.isCompleted())
.sorted(Comparator.comparingInt(TodoInstance::getSortOrder))
.toList();
List<TodoInstance> existingCompleted = existingInstances.stream()
.filter(TodoInstance::isCompleted)
.sorted(Comparator.comparingInt(TodoInstance::getSortOrder))
.toList();

int sortOrder = 0;
for (TodoInstance instance : newInstances) {
instance.updateSortOrder(sortOrder++);
result.put(instance.getTodo().getId(), instance);
}
for (TodoInstance instance : existingIncomplete) {
instance.updateSortOrder(sortOrder++);
}
for (TodoInstance instance : existingCompleted) {
instance.updateSortOrder(sortOrder++);
}

return result;
}

private void moveToCompletedBottom(TodoInstance target, Iterable<TodoInstance> group) {
Expand Down
15 changes: 9 additions & 6 deletions src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,16 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS
.orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND));

LocalDate date = resolveDate(userId, request.date());
if (!todoDateCalculator.occursOn(todo, date)) {
throw new CustomException(TodoErrorCode.TODO_NOT_FOUND);
}

if (timerService.hasActiveTimer(todoId)) {
throw new CustomException(TodoErrorCode.TIMER_RUNNING);
}
boolean instanceExists = todoInstanceRepository.findByTodo_IdAndDate(todoId, date).isPresent();

if (!instanceExists && !todoDateCalculator.occursOn(todo, date)) {
throw new CustomException(TodoErrorCode.TODO_NOT_FOUND);
}

if (timerService.hasActiveTimer(todoId)) {
throw new CustomException(TodoErrorCode.TIMER_RUNNING);
}

TodoInstance instance = todoInstanceReorderer.applyCompletion(userId, todo, date, request.isCompleted());
return TodoStatusChangeResponse.from(todoId, instance);
Expand Down