From 3e8648d8d77b8a1c2dcaaf7b37d0fef3f20e4082 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 16 Jul 2026 03:22:53 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EA=B7=9C=EC=B9=99=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=EC=9D=BC=EC=9D=B4=20=EC=95=84=EB=8B=88=EC=96=B4?= =?UTF-8?q?=EB=8F=84=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=A9=B4=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/service/TodoService.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java index 1d70033b..15af99f2 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -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); From 81f5d2f730c9bc9079584715676209a1c2444835 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 16 Jul 2026 05:17:21 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20materializeDayGroup=EC=9D=B4=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B0=9C=EC=83=9D=EC=9D=BC=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=EB=8F=84=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoInstanceReorderer.java | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java index be2e230d..feb8b3e0 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java @@ -85,32 +85,26 @@ private TodoInstance requireTarget(Map instancesByTodoId, To } private Map materializeDayGroup(Long userId, LocalDate date) { - List occurringRules = todoRepository.findRulesInRange(userId, date, date).stream() - .filter(rule -> todoDateCalculator.occursOn(rule, date)) - .toList(); - - List todoIds = occurringRules.stream() - .map(Todo::getId) - .toList(); - - Map existing = todoIds.isEmpty() - ? Map.of() - : todoInstanceRepository.findByTodoIdsAndDateRange(todoIds, date, date).stream() - .collect(Collectors.toMap( - instance -> instance.getTodo().getId(), - Function.identity() - )); - - Map 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 result = new LinkedHashMap<>(); + + List existingInstances = todoInstanceRepository.findDailyInstances(userId, date); + for (TodoInstance instance : existingInstances) { + result.put(instance.getTodo().getId(), instance); + } + + List occurringRules = todoRepository.findRulesInRange(userId, date, date).stream() + .filter(rule -> todoDateCalculator.occursOn(rule, date)) + .toList(); + + int nextSortOrder = result.size(); + for (Todo rule : occurringRules) { + if (!result.containsKey(rule.getId())) { + TodoInstance created = todoInstanceRepository.save(TodoInstance.of(rule, date, nextSortOrder++)); + result.put(rule.getId(), created); + } + } + + return result; } private void moveToCompletedBottom(TodoInstance target, Iterable group) { From b1cbdd1ffb569f2d1c3424afab6c0e369a71469a Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:42:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(timer):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=EB=A5=BC=20=EC=8B=A4=ED=96=89=EC=8B=9C=ED=82=A4?= =?UTF-8?q?=EB=8A=94=20=EB=82=A0=EC=A7=9C=20=EC=A0=80=EC=9E=A5=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java index 2248b042..207dbb55 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -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; @@ -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; } From 019e1c9357a53962beb4af202d74de742c0138f9 Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:43:03 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(timer):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=EA=B0=80=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=A0=20=EB=82=A0=EC=A7=9C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/timer/controller/TimerController.java | 7 ++++++- .../domain/timer/docs/TimerStartControllerDocs.java | 8 +++++++- .../Timo/domain/timer/service/TimerService.java | 13 +++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java index c0ec6813..c2809b36 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java +++ b/src/main/java/com/Timo/Timo/domain/timer/controller/TimerController.java @@ -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; @@ -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; @@ -45,11 +48,13 @@ public class TimerController implements TimerStartControllerDocs, TimerStatusCon @PostMapping("/todos/{todoId}/timers/start") public ResponseEntity> 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)); diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java index ea6274e6..eb8ebe7b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java @@ -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 { @@ -21,8 +24,8 @@ public interface TimerStartControllerDocs { 투두의 타이머를 시작합니다. 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. - 응답의 date는 타이머가 귀속되는 날짜(사용자 시간대 기준 시작일)입니다. 반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다. + 쿼리 파라미터 date로 타이머가 상태를 반영할 대상 날짜를 지정할 수 있으며(미래/과거 포함), 미지정 시 사용자 타임존 기준 오늘로 처리됩니다. 이후 일시정지/재개/완료/중지는 이 날짜를 그대로 사용합니다. """ ) @ApiResponses({ @@ -75,6 +78,9 @@ public interface TimerStartControllerDocs { ResponseEntity> 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 ); } diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index a2015255..918023de 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -51,7 +51,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) @@ -72,11 +72,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); @@ -176,7 +181,11 @@ private TodoInstance getOrCreateInstance(Todo todo, LocalDate date) { } private LocalDate resolveTimerDate(TimerRecord timerRecord) { - return timerRecord.getTimerDate(); + if (timerRecord.getTargetDate() != null) { + return timerRecord.getTargetDate(); + } + ZoneId userZone = ZoneId.of(timerRecord.getUser().getZoneId()); + return timerRecord.getStartedAt().atZone(ZoneOffset.UTC).withZoneSameInstant(userZone).toLocalDate(); } public boolean hasActiveTimer(Long todoId) { From d814e3ef5aea5771140ed57234b03d6ee6a7ba7a Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:43:36 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor(todo):=20todo=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/service/TodoInstanceReorderer.java | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java index feb8b3e0..f2fae983 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoInstanceReorderer.java @@ -78,7 +78,6 @@ public TodoInstance applyReorder(Long userId, Todo targetRule, LocalDate date, I private TodoInstance requireTarget(Map instancesByTodoId, Todo targetRule) { TodoInstance target = instancesByTodoId.get(targetRule.getId()); if (target == null) { - // 해당 날짜에 발생하지 않는 규칙이면 그룹에 인스턴스가 없다. throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); } return target; @@ -96,12 +95,35 @@ private Map materializeDayGroup(Long userId, LocalDate date) .filter(rule -> todoDateCalculator.occursOn(rule, date)) .toList(); - int nextSortOrder = result.size(); - for (Todo rule : occurringRules) { - if (!result.containsKey(rule.getId())) { - TodoInstance created = todoInstanceRepository.save(TodoInstance.of(rule, date, nextSortOrder++)); - result.put(rule.getId(), created); - } + List 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 existingIncomplete = existingInstances.stream() + .filter(instance -> !instance.isCompleted()) + .sorted(Comparator.comparingInt(TodoInstance::getSortOrder)) + .toList(); + List 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; From 01d882fbcff993e559a28e9767efedbdc5ff612b Mon Sep 17 00:00:00 2001 From: laura-jung <166522604+laura-jung@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:01:34 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor(todo):=20=EC=9D=91=EB=8B=B5=20date?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=20targetDate=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 3 +++ .../com/Timo/Timo/domain/timer/service/TimerService.java | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java index 207dbb55..65dc80f4 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -136,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(); } diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index 918023de..7517fa4c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -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; @@ -181,11 +182,7 @@ private TodoInstance getOrCreateInstance(Todo todo, LocalDate date) { } private LocalDate resolveTimerDate(TimerRecord timerRecord) { - if (timerRecord.getTargetDate() != null) { - return timerRecord.getTargetDate(); - } - ZoneId userZone = ZoneId.of(timerRecord.getUser().getZoneId()); - return timerRecord.getStartedAt().atZone(ZoneOffset.UTC).withZoneSameInstant(userZone).toLocalDate(); + return timerRecord.getTimerDate(); } public boolean hasActiveTimer(Long todoId) {