Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
49157c9
feat: 하위 태스크를 기본 순서로 정렬하고 TODO 수정 시 순서를 유지
laura-jung Oct 12, 2026
e539971
feat: 하위 태스크 날짜별 순서 저장 컬럼과 정렬 기준 추가
laura-jung Oct 12, 2026
20e579a
feat: TODO 상세·홈 조회에 날짜별 하위 태스크 순서 반영
laura-jung Oct 12, 2026
be401b0
feat: 하위 태스크 날짜별 순서 재정렬 로직 추가
laura-jung Oct 12, 2026
ea9727a
feat: 하위 태스크 순서 변경 API 추가
laura-jung Oct 12, 2026
483b7e3
docs: TODO 수정 API의 하위 태스크 순서 처리 설명 수정
laura-jung Oct 12, 2026
fb86531
fix: 하위 태스크 순서 변경 시 검증 전에 레코드를 저장하지 않도록 수정
laura-jung Oct 14, 2026
5c93700
fix: 같은 날짜의 하위 태스크 요청을 인스턴스 잠금으로 순차 처리
laura-jung Oct 14, 2026
5509b17
feat: 하위 태스크 완료 시 TODO와 같은 규칙으로 순서 이동
laura-jung Oct 14, 2026
84cf397
fix: 하위 태스크 완료 레코드를 잠금 조회로 읽어 최신 값 기준으로 처리
laura-jung Oct 14, 2026
98a19f8
refactor: SubtaskStates 내부 컬렉션을 불변으로 보관
laura-jung Oct 14, 2026
9bf0ede
refactor: 하위 태스크 수정 시 null 필터를 Objects::nonNull로 변경
laura-jung Oct 14, 2026
b7041cd
fix: MySQL에서 적용되지 않는 잠금 대기 시간 힌트 제거
laura-jung Oct 15, 2026
e31f340
[merge] #223 - 하위 태스크 날짜별 순서 변경 API
laura-jung Oct 15, 2026
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
@@ -1,7 +1,6 @@
package com.Timo.Timo.domain.home.mapper;

import java.util.List;
import java.util.Set;

import org.springframework.stereotype.Component;

Expand All @@ -14,6 +13,7 @@
import com.Timo.Timo.domain.todo.entity.TodoInstance;
import com.Timo.Timo.domain.todo.enums.RepeatType;
import com.Timo.Timo.domain.todo.enums.TodoTimerStatus;
import com.Timo.Timo.domain.todo.vo.SubtaskStates;

@Component
public class HomeTodoMapper {
Expand All @@ -37,7 +37,7 @@ public TodoResponse toResponse(TodoContext context) {
todo.isDisconnectedFromGoogle(),
resolveTimerStatus(instance),
instance != null ? instance.getSortOrder() : context.defaultSortOrder(),
mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds())
mapSubtasks(todo.getSubtasks(), context.subtaskStates())
);
}

Expand All @@ -54,9 +54,9 @@ private TodoTimerStatus resolveTimerStatus(TodoInstance instance) {
return instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED;
}

private List<SubtaskResponse> mapSubtasks(List<Subtask> subtasks, Set<Long> completedSubtaskIds) {
return subtasks.stream()
.map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId())))
private List<SubtaskResponse> mapSubtasks(List<Subtask> subtasks, SubtaskStates subtaskStates) {
return subtaskStates.sort(subtasks).stream()
.map(subtask -> SubtaskResponse.from(subtask, subtaskStates.isCompleted(subtask.getId())))
.toList();
}

Expand All @@ -65,7 +65,7 @@ public record TodoContext(
TodoInstance instance,
Tag tag,
int defaultSortOrder,
Set<Long> completedSubtaskIds
SubtaskStates subtaskStates
) {
}
}
30 changes: 14 additions & 16 deletions src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

Expand All @@ -18,13 +17,13 @@
import com.Timo.Timo.domain.home.mapper.HomeTodoMapper.TodoContext;
import com.Timo.Timo.domain.tag.entity.Tag;
import com.Timo.Timo.domain.tag.repository.TagRepository;
import com.Timo.Timo.domain.todo.entity.SubtaskCompletion;
import com.Timo.Timo.domain.todo.entity.Todo;
import com.Timo.Timo.domain.todo.entity.TodoInstance;
import com.Timo.Timo.domain.todo.repository.SubtaskCompletionRepository;
import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository;
import com.Timo.Timo.domain.todo.repository.TodoRepository;
import com.Timo.Timo.domain.todo.service.TodoDateCalculator;
import com.Timo.Timo.domain.todo.vo.SubtaskStates;

import lombok.RequiredArgsConstructor;

Expand All @@ -42,8 +41,8 @@ public class HomeTodoReader {
public LoadedTodos load(Long userId, LocalDate from, LocalDate to) {
List<Todo> rules = todoRepository.findRulesInRange(userId, from, to);
Map<InstanceKey, TodoInstance> instances = loadInstances(rules, from, to);
Map<Long, Set<Long>> completedSubtaskIds = loadCompletedSubtaskIds(instances.values());
return new LoadedTodos(rules, instances, loadTags(rules), completedSubtaskIds);
Map<Long, SubtaskStates> subtaskStates = loadSubtaskStates(instances.values());
return new LoadedTodos(rules, instances, loadTags(rules), subtaskStates);
}

public List<TodoResponse> sortedTodosOn(LoadedTodos loaded, LocalDate date) {
Expand All @@ -63,7 +62,7 @@ public List<TodoResponse> sortedTodosOn(LoadedTodos loaded, LocalDate date) {
Tag tag = rule.getTagId() == null ? null : loaded.tagsById().get(rule.getTagId());
todos.add(homeTodoMapper.toResponse(
new TodoContext(rule, entry.instance(), tag, index,
completedSubtaskIdsFor(loaded, entry.instance()))
subtaskStatesFor(loaded, entry.instance()))
));
}

Expand All @@ -73,31 +72,30 @@ public List<TodoResponse> sortedTodosOn(LoadedTodos loaded, LocalDate date) {
public TodoResponse todoResponseOn(Todo rule, LocalDate date) {
TodoInstance instance = todoInstanceRepository.findByTodo_IdAndDate(rule.getId(), date).orElse(null);
Tag tag = rule.getTagId() == null ? null : tagRepository.findById(rule.getTagId()).orElse(null);
Set<Long> completedSubtaskIds = instance == null
? Set.of()
: loadCompletedSubtaskIds(List.of(instance)).getOrDefault(instance.getId(), Set.of());
return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0, completedSubtaskIds));
SubtaskStates subtaskStates = instance == null
? SubtaskStates.EMPTY
: loadSubtaskStates(List.of(instance)).getOrDefault(instance.getId(), SubtaskStates.EMPTY);
return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0, subtaskStates));
}

private Set<Long> completedSubtaskIdsFor(LoadedTodos loaded, TodoInstance instance) {
private SubtaskStates subtaskStatesFor(LoadedTodos loaded, TodoInstance instance) {
if (instance == null) {
return Set.of();
return SubtaskStates.EMPTY;
}
return loaded.completedSubtaskIdsByInstanceId().getOrDefault(instance.getId(), Set.of());
return loaded.subtaskStatesByInstanceId().getOrDefault(instance.getId(), SubtaskStates.EMPTY);
}

private Map<Long, Set<Long>> loadCompletedSubtaskIds(Collection<TodoInstance> instances) {
private Map<Long, SubtaskStates> loadSubtaskStates(Collection<TodoInstance> instances) {
List<Long> instanceIds = instances.stream()
.map(TodoInstance::getId)
.toList();
if (instanceIds.isEmpty()) {
return Map.of();
}
return subtaskCompletionRepository.findByTodoInstance_IdIn(instanceIds).stream()
.filter(SubtaskCompletion::isCompleted)
.collect(Collectors.groupingBy(
completion -> completion.getTodoInstance().getId(),
Collectors.mapping(completion -> completion.getSubtask().getId(), Collectors.toSet())
Collectors.collectingAndThen(Collectors.toList(), SubtaskStates::from)
));
}

Expand Down Expand Up @@ -163,7 +161,7 @@ public record LoadedTodos(
List<Todo> rules,
Map<InstanceKey, TodoInstance> instancesByKey,
Map<Long, Tag> tagsById,
Map<Long, Set<Long>> completedSubtaskIdsByInstanceId
Map<Long, SubtaskStates> subtaskStatesByInstanceId
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
import org.springframework.web.bind.annotation.RestController;

import com.Timo.Timo.domain.todo.docs.TodoControllerDocs;
import com.Timo.Timo.domain.todo.dto.request.SubtaskReorderRequest;
import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoMemoUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest;
import com.Timo.Timo.domain.todo.dto.response.SubtaskReorderResponse;
import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse;
import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse;
import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse;
Expand Down Expand Up @@ -132,6 +134,22 @@ public ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
.body(BaseResponse.onSuccess(TodoSuccessCode.REORDERED, response));
}

@Override
@PatchMapping("/{todoId}/subtasks/{subtaskId}/order")
public ResponseEntity<BaseResponse<SubtaskReorderResponse>> reorderSubtask(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long todoId,
@PathVariable Long subtaskId,
@Valid @RequestBody SubtaskReorderRequest request
) {
SubtaskReorderResponse response =
todoService.reorderSubtask(userDetails.getUserId(), todoId, subtaskId, request);

return ResponseEntity
.status(TodoSuccessCode.SUBTASK_REORDERED.getHttpStatus())
.body(BaseResponse.onSuccess(TodoSuccessCode.SUBTASK_REORDERED, response));
}

@Override
@PatchMapping("/{todoId}")
public ResponseEntity<BaseResponse<Object>> updateTodo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

import org.springframework.http.ResponseEntity;

import com.Timo.Timo.domain.todo.dto.request.SubtaskReorderRequest;
import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoMemoUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest;
import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest;
import com.Timo.Timo.domain.todo.dto.response.SubtaskReorderResponse;
import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse;
import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse;
import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse;
Expand Down Expand Up @@ -276,6 +278,7 @@ ResponseEntity<BaseResponse<TodoStatusChangeResponse>> changeTodoStatus(
예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다.
subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다.
subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다.
subtasks 배열 순서로 하위 태스크 순서를 바꾸지 않습니다. 신규 태스크는 맨 뒤에 추가되며, 순서 변경은 하위 태스크 순서 변경 API를 사용합니다.

구글 캘린더에서 가져온 일정은 구글이 원본이므로 제목, 날짜(반복 규칙 포함)를 수정할 수 없습니다.
소요 시간, 아이콘, 우선순위, 태그, 하위 태스크는 수정할 수 있습니다.
Expand Down Expand Up @@ -519,6 +522,8 @@ ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
하위 태스크의 완료 여부를 변경합니다.

todoId로 소유한 TODO를 확인한 뒤, 해당 TODO에 속한 subtaskId의 완료 상태를 변경합니다.
완료하면 해당 날짜 완료 그룹의 맨 아래로, 완료를 취소하면 미완료 그룹의 맨 위로 이동하며 해당 날짜의 하위 태스크 순서를 재계산합니다.
응답의 sortOrder는 해당 날짜에서 확정된 정렬 순서입니다.

Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다.
"""
Expand Down Expand Up @@ -569,6 +574,14 @@ ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
schema = @Schema(implementation = ErrorDto.class)
)
),
@ApiResponse(
responseCode = "409",
description = "같은 날짜의 다른 요청이 처리 중이어서 잠금 대기 시간을 초과한 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorDto.class)
)
),
@ApiResponse(
responseCode = "500",
description = "서버 내부 오류",
Expand All @@ -587,6 +600,91 @@ ResponseEntity<BaseResponse<SubtaskStatusChangeResponse>> changeSubtaskStatus(
SubtaskStatusUpdateRequest request
);

@Operation(
summary = "하위 태스크 순서 변경",
description = """
드래그 앤 드롭으로 변경된 하위 태스크의 순서를 반영합니다.

하위 태스크 순서는 날짜별로 관리되며, 요청한 날짜에만 적용됩니다. 순서를 바꾸지 않은 날짜는 기본 순서를 따릅니다.
순서 변경은 같은 날짜의 미완료 하위 태스크 그룹 내부에서만 가능하며, 완료된 하위 태스크는 순서를 변경할 수 없습니다.
newIndex는 해당 날짜 미완료 그룹 내에서 0부터 시작하는 목표 위치입니다.
date를 생략하면 사용자 타임존 기준 오늘로 처리합니다.
서버는 대상 하위 태스크를 새 인덱스로 옮기고, 미완료 그룹을 0부터 재정렬한 뒤 완료 그룹을 그 아래에 배치합니다.

Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다.
"""
)
@RequestBody(
required = true,
description = "하위 태스크 순서 변경 요청",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SubtaskReorderRequest.class),
examples = @ExampleObject(
name = "하위 태스크 순서 변경 요청 예시",
value = """
{
"newIndex": 0,
"date": "2026-10-09"
}
"""
)
)
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "하위 태스크 순서 변경 성공",
useReturnTypeSchema = true
),
@ApiResponse(
responseCode = "400",
description = "newIndex가 누락되었거나 음수이거나 미완료 그룹 크기를 초과하는 경우, 요청 형식이 잘못된 경우",
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 = "404",
description = "존재하지 않는 TODO이거나 해당 날짜에 발생하지 않는 TODO인 경우, 해당 TODO에 속하지 않는 하위 태스크인 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorDto.class)
)
),
@ApiResponse(
responseCode = "409",
description = "완료된 하위 태스크의 순서를 변경하려는 경우, 같은 날짜의 다른 요청이 처리 중이어서 잠금 대기 시간을 초과한 경우",
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<SubtaskReorderResponse>> reorderSubtask(
@Parameter(hidden = true) CustomUserDetails userDetails,
@Parameter(description = "대상 TODO ID", example = "145") Long todoId,
@Parameter(description = "이동할 하위 태스크 ID", example = "12") Long subtaskId,
SubtaskReorderRequest request
);

@Operation(
summary = "메모 수정 (날짜별)",
description = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.Timo.Timo.domain.todo.dto.request;

import java.time.LocalDate;

public record SubtaskReorderRequest(
Integer newIndex,

LocalDate date
) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.Timo.Timo.domain.todo.dto.response;

import com.Timo.Timo.domain.todo.entity.SubtaskCompletion;

import io.swagger.v3.oas.annotations.media.Schema;

public record SubtaskReorderResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Long subtaskId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer sortOrder
) {

public static SubtaskReorderResponse from(SubtaskCompletion completion) {
return new SubtaskReorderResponse(
completion.getSubtask().getId(),
completion.getSortOrder()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
package com.Timo.Timo.domain.todo.dto.response;

import com.Timo.Timo.domain.todo.entity.SubtaskCompletion;

import io.swagger.v3.oas.annotations.media.Schema;

public record SubtaskStatusChangeResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Long subtaskId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Boolean completed
Boolean completed,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Integer sortOrder
) {

public static SubtaskStatusChangeResponse from(SubtaskCompletion completion) {
return new SubtaskStatusChangeResponse(
completion.getSubtask().getId(),
completion.isCompleted(),
completion.getSortOrder()
);
}
}
Loading
Loading