Skip to content
6 changes: 6 additions & 0 deletions db/migration/2026-07-16__drop_subtasks_completed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- 하위 태스크 완료가 날짜별(subtask_completions)로 이관되어 규칙 레벨 subtasks.completed는 미사용이 되었다.
-- ddl-auto: update는 컬럼을 자동 삭제하지 않으므로, 기존 DB에서는 이 SQL로 제거해야
-- 새 하위 태스크 INSERT 시 NOT NULL 위반이 발생하지 않는다.

ALTER TABLE subtasks
DROP COLUMN completed;
13 changes: 11 additions & 2 deletions src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import com.Timo.Timo.domain.timer.enums.TimerStatus;
import com.Timo.Timo.domain.timer.repository.TimerRecordRepository;
import com.Timo.Timo.domain.todo.entity.Todo;
import com.Timo.Timo.domain.todo.entity.TodoInstance;
import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository;
import com.Timo.Timo.domain.user.entity.User;
import com.Timo.Timo.domain.user.exception.UserErrorCode;
import com.Timo.Timo.domain.user.repository.UserRepository;
Expand All @@ -34,6 +36,7 @@ public class FocusService {
private final UserRepository userRepository;
private final HomeTodoReader homeTodoReader;
private final TimerRecordRepository timerRecordRepository;
private final TodoInstanceRepository todoInstanceRepository;

public FocusTodoResult getFocusTodo(Long userId) {
User user = getUser(userId);
Expand Down Expand Up @@ -64,7 +67,7 @@ public FocusTodoResult getFocusTodo(Long userId) {
String memo = loaded.rules().stream()
.filter(rule -> rule.getId().equals(focusTodo.todoId()))
.findFirst()
.map(Todo::getMemo)
.map(rule -> resolveMemo(rule, today))
.orElse(null);

return new FocusTodoResult(FocusSuccessCode.GET_FOCUS_TODO, FocusTodoResponse.of(today, focusTodo, memo));
Expand All @@ -85,10 +88,16 @@ private FocusTodoResult resolveActiveTimerFocus(Long userId) {

return new FocusTodoResult(
FocusSuccessCode.GET_FOCUS_TODO,
FocusTodoResponse.of(timerDate, focusTodo, todo.getMemo())
FocusTodoResponse.of(timerDate, focusTodo, resolveMemo(todo, timerDate))
);
}

private String resolveMemo(Todo rule, LocalDate date) {
return todoInstanceRepository.findByTodo_IdAndDate(rule.getId(), date)
.map(TodoInstance::resolveMemo)
.orElse(rule.getMemo());
}

private User getUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ public record SubtaskResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean completed
) {
public static SubtaskResponse from(Subtask subtask) {
public static SubtaskResponse from(Subtask subtask, boolean completed) {
return new SubtaskResponse(
subtask.getId(),
subtask.getContent(),
subtask.isCompleted()
completed
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.Timo.Timo.domain.home.mapper;

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

import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -33,7 +34,7 @@ public TodoResponse toResponse(TodoContext context) {
todo.getRepeatType() != RepeatType.NONE,
resolveTimerStatus(instance),
instance != null ? instance.getSortOrder() : context.defaultSortOrder(),
mapSubtasks(todo.getSubtasks())
mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds())
);
}

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

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

public record TodoContext(
Todo todo,
TodoInstance instance,
Tag tag,
int defaultSortOrder
int defaultSortOrder,
Set<Long> completedSubtaskIds
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
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 @@ -16,8 +18,10 @@
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;
Expand All @@ -33,10 +37,13 @@ public class HomeTodoReader {
private final TagRepository tagRepository;
private final TodoDateCalculator todoDateCalculator;
private final HomeTodoMapper homeTodoMapper;
private final SubtaskCompletionRepository subtaskCompletionRepository;

public LoadedTodos load(Long userId, LocalDate from, LocalDate to) {
List<Todo> rules = todoRepository.findRulesInRange(userId, from, to);
return new LoadedTodos(rules, loadInstances(rules, from, to), loadTags(rules));
Map<InstanceKey, TodoInstance> instances = loadInstances(rules, from, to);
Map<Long, Set<Long>> completedSubtaskIds = loadCompletedSubtaskIds(instances.values());
return new LoadedTodos(rules, instances, loadTags(rules), completedSubtaskIds);
}

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

Expand All @@ -65,7 +73,32 @@ 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);
return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0));
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));
}

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

private Map<Long, Set<Long>> loadCompletedSubtaskIds(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())
));
}

private static final Comparator<TodoOnDate> TODO_ORDER = Comparator
Expand Down Expand Up @@ -129,7 +162,8 @@ private Map<Long, Tag> loadTags(List<Todo> rules) {
public record LoadedTodos(
List<Todo> rules,
Map<InstanceKey, TodoInstance> instancesByKey,
Map<Long, Tag> tagsById
Map<Long, Tag> tagsById,
Map<Long, Set<Long>> completedSubtaskIdsByInstanceId
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public interface TimerStartControllerDocs {
ResponseEntity<BaseResponse<TimerStartResponse>> startTimer(
@Parameter(description = "타이머를 시작할 투두 ID", example = "3")
@PathVariable Long todoId,
@Parameter(description = "타이머가 상태를 반영할 대상 날짜(미지정 시 오늘). ISO 형식", example = "2026-07-20")
@Parameter(description = "대상 날짜(쿼리 파라미터). 미지정 시 사용자 타임존 기준 오늘", example = "2026-07-20")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@Parameter(hidden = true) CustomUserDetails userDetails
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.Timo.Timo.domain.todo.controller;

import java.time.LocalDate;
import java.util.Map;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
Expand All @@ -17,6 +19,7 @@
import com.Timo.Timo.domain.todo.docs.TodoControllerDocs;
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;
Expand Down Expand Up @@ -89,16 +92,32 @@ public ResponseEntity<BaseResponse<SubtaskStatusChangeResponse>> changeSubtaskSt
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long todoId,
@PathVariable Long subtaskId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@Valid @RequestBody SubtaskStatusUpdateRequest request
) {
SubtaskStatusChangeResponse response =
todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, request);
todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, date, request);

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

@Override
@PatchMapping("/{todoId}/memo")
public ResponseEntity<BaseResponse<Object>> updateMemo(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long todoId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@Valid @RequestBody TodoMemoUpdateRequest request
) {
todoService.updateMemo(userDetails.getUserId(), todoId, date, request.memo());

return ResponseEntity
.status(TodoSuccessCode.UPDATED.getHttpStatus())
.body(BaseResponse.onSuccess(TodoSuccessCode.UPDATED, Map.of()));
}

@Override
@PatchMapping("/{todoId}/order")
public ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.Timo.Timo.domain.todo.docs;

import java.time.LocalDate;

import org.springframework.http.ResponseEntity;

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;
Expand Down Expand Up @@ -289,10 +292,9 @@ ResponseEntity<BaseResponse<TodoStatusChangeResponse>> changeTodoStatus(
{
"title": "티모 하이와프 작업하기 v2",
"priority": "VERY_HIGH",
"memo": "레퍼런스 정리 먼저 → API 명세",
"subtasks": [
{ "subtaskId": 1, "content": "타이머 명세 초안", "completed": true },
{ "subtaskId": null, "content": "리뷰 반영", "completed": false }
{ "subtaskId": 1, "content": "타이머 명세 초안" },
{ "subtaskId": null, "content": "리뷰 반영" }
]
}
"""
Expand Down Expand Up @@ -558,6 +560,33 @@ ResponseEntity<BaseResponse<SubtaskStatusChangeResponse>> changeSubtaskStatus(
@Parameter(hidden = true) CustomUserDetails userDetails,
@Parameter(description = "대상 TODO ID", example = "5") Long todoId,
@Parameter(description = "대상 하위 태스크 ID", example = "2") Long subtaskId,
@Parameter(description = "완료 상태를 변경할 날짜 (ISO). 반복 TODO는 날짜별로 완료가 분리됩니다.", example = "2026-07-20")
LocalDate date,
SubtaskStatusUpdateRequest request
);

@Operation(
summary = "메모 수정 (날짜별)",
description = """
해당 날짜의 메모를 수정합니다.
메모는 생성 시에만 모든 날짜에 공유되고, 이후 수정은 요청한 date의 인스턴스에만 저장됩니다(다른 날짜는 규칙 메모로 폴백).
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "메모 수정 성공", useReturnTypeSchema = true),
@ApiResponse(
responseCode = "404",
description = "존재하지 않는 TODO이거나 해당 날짜에 발생하지 않는 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorDto.class)
)
)
})
ResponseEntity<BaseResponse<Object>> updateMemo(
@Parameter(hidden = true) CustomUserDetails userDetails,
@Parameter(description = "대상 TODO ID", example = "5") Long todoId,
@Parameter(description = "메모를 저장할 날짜 (ISO)", example = "2026-07-20") LocalDate date,
TodoMemoUpdateRequest request
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.Timo.Timo.domain.todo.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;

public record TodoMemoUpdateRequest(
@Size(max = 300)
@Schema(description = "해당 날짜에 저장할 메모 (null이면 규칙 메모로 폴백)", example = "오늘은 30분만")
String memo
) { }
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@
import com.Timo.Timo.domain.todo.validation.ValidSubtaskContent;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record TodoSubtaskUpdateRequest(
Long subtaskId,

@NotBlank
@ValidSubtaskContent
String content,

@NotNull
Boolean completed
String content
) { }
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;

public record TodoUpdateRequest(
TodoIcon icon,
Expand All @@ -37,9 +36,6 @@ public record TodoUpdateRequest(
@Max(31)
Integer repeatDayOfMonth,

@Size(max = 300)
String memo,

@Valid
List<TodoSubtaskUpdateRequest> subtasks
) {
Expand All @@ -53,7 +49,6 @@ public boolean hasNoUpdatableField() {
&& repeatType == null
&& repeatWeekdays == null
&& repeatDayOfMonth == null
&& memo == null
&& subtasks == null;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.Timo.Timo.domain.todo.dto.response;

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

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

public record SubtaskStatusChangeResponse(
Expand All @@ -10,10 +8,4 @@ public record SubtaskStatusChangeResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Boolean completed
) {
public static SubtaskStatusChangeResponse from(Subtask subtask) {
return new SubtaskStatusChangeResponse(
subtask.getId(),
subtask.isCompleted()
);
}
}
Loading