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
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