-
Notifications
You must be signed in to change notification settings - Fork 1
아이폰 캘린더 연동 & 토덕 서버의 일정과 통합하여 함께 표시 #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
Walkthrough서버 일정과 로컬 캘린더 일정을 분리·통합하는 구조로 리팩터링했습니다. Schedule에 source를 추가하고 관련 이니셜라이저·매핑을 갱신했습니다. 저장소 계층에 ScheduleStorage를 도입해 EventKit으로 캘린더 이벤트를 조회합니다. 도메인 유스케이스/어셈블리/프레젠테이션을 이에 맞게 교체하고, 권한 안내(Info.plist)와 권한 체크 로직을 추가했습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor UI as Home/Calendars UI
participant VM as ViewModel (Toduck/Todo)
participant UCAll as FetchAllSchedulesUseCase
participant UCServer as FetchServerScheduleListUseCase
participant UCLocal as FetchLocalCalendarScheduleListUseCase
participant Repo as ScheduleRepository
participant Svc as ScheduleService
participant Stor as ScheduleStorage (EventKit)
UI->>VM: 요청(startDate, endDate)
VM->>UCAll: execute(startDate, endDate)
par 병렬 실행
UCAll->>UCServer: execute(startDate, endDate)
UCAll->>UCLocal: execute(startDate, endDate)
and
end
UCServer->>Repo: fetchServerScheduleList(startDate, endDate)
Repo->>Svc: 서버 일정 조회
Svc-->>Repo: [Schedule(source: .server)...]
UCLocal->>Repo: fetchLocalCalendarScheduleList(startDate, endDate)
Repo->>Stor: fetchEvents(from:to:)
Stor-->>Repo: [EKEvent...]
Repo-->>UCLocal: [Schedule(source: .localCalendar)...]
UCServer-->>UCAll: [Date: [Schedule]]
UCLocal-->>UCAll: [Date: [Schedule]]
UCAll-->>VM: 병합된 [Date: [Schedule]]
VM-->>UI: 화면 갱신
sequenceDiagram
autonumber
actor App as App
participant Tab as MainTabBarCoordinator
participant EK as EKEventStore
participant APN as UNUserNotificationCenter
App->>Tab: 탭바 구성 시작
Tab->>APN: requestAuthorization(alert,sound,badge)
APN-->>Tab: granted/denied
alt granted
Tab->>App: registerForRemoteNotifications()
else denied
Tab-->>App: 로그/플래그 업데이트
end
Tab->>EK: authorizationStatus(.event)
alt notDetermined
Tab->>EK: requestAccess(.event)
EK-->>Tab: granted/denied
else authorized/denied/restricted
Tab-->>App: 상태 로그
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
Status, Documentation and Community
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift (1)
271-279: isAllDay 산출 로직이 사용자 입력을 덮어쓸 수 있음현재
isAllDay를time == nil로 재계산하여, 사용자가 명시적으로isAllDay = true로 선택했더라도time이 존재하면 false로 뒤집힐 수 있습니다. 저장/수정 시 혼선을 유발합니다.사용자 입력을 우선하고, 입력이 없을 때만 유도 값을 쓰도록 변경 제안:
- let isAllDay = time == nil + let isAllDay = self.isAllDay ?? (time == nil)Projects/TDDomain/Sources/Entity/Schedule.swift (2)
25-27: isRepeating 계산 버그: 빈 배열([])도 반복으로 취급됨현재 로직은
repeatDays != nil이면 빈 배열이어도 반복으로 간주합니다.[]는 “반복 없음”이므로 잘못된 상태입니다. 이로 인해 UI/로직에서 반복 항목으로 잘못 분류될 수 있습니다.수정 제안:
- public var isRepeating: Bool { - repeatDays != nil || startDate != endDate - } + public var isRepeating: Bool { + (repeatDays?.isEmpty == false) || (startDate != endDate) + }
29-44: [긴급]Schedule이니셜라이저source:파라미터 누락 호출부 수정 필요
Schedule.init(…)에source: Source매개변수가 필수로 변경되어, 아래 호출부에서 누락되어 있습니다. 각 위치에 적절한Source값을 넘기도록 수정해주세요:
- TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift: 70
- TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift: 33, 35
- TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift: 30, 48, 75, 97, 119, 136
- TDDomain/Tests/Mock/MockScheduleRepository.swift: 35
- TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift: 117, 140
- TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift: 272
- TDData/Sources/Repository/ScheduleRepositoryImpl.swift: 50
- TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift: 165
- TDData/Sources/DTO/ScheduleListResponseDTO.swift: 45
추가 누락된 호출을 자동 검출하려면, CI에 아래 스크립트를 적용하시거나 전체 호출부를 다시 점검하시기 바랍니다:
#!/bin/bash # Schedule 생성자 호출 중 source: 누락된 후보 찾기 rg -nP --type=swift -C2 '\bSchedule\s*\(' | rg -nP -v 'source\s*:'Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift (1)
20-27: FetchAllSchedulesUseCase 적용 및 ViewModel/DI 설정 수정 필요현재 PR 목표인 “서버 일정 + 로컬 캘린더 일정 통합”을 위해 도입된
FetchAllSchedulesUseCase를 실제로 사용하려면 다음과 같은 필수 수정 사항이 있습니다.
- ToduckCalendarViewModel의 프로퍼티 및 이니셜라이저를
FetchServerScheduleListUseCase에서FetchAllSchedulesUseCase로 변경해야 합니다.- ToduckCalendarCoordinator에서
injector.resolve(FetchServerScheduleListUseCase.self)대신injector.resolve(FetchAllSchedulesUseCase.self)를 주입하도록 업데이트해야 합니다.- DIContainer(또는 injector)에
FetchAllSchedulesUseCase의 구현체 등록이 현재 되어 있지 않으므로,DomainAssembly.swift등에container.register(FetchAllSchedulesUseCase.self) { resolver in … }구문을 추가해야 합니다.- DI 호출 방식(
injector.resolvevsDIContainer.shared.resolve) 및 수동 인스턴스화(FetchAllSchedulesUseCaseImpl(...))를 한 가지 방식으로 통일해 테스트 편의성과 가독성을 높여주세요.점검 위치
Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swiftProjects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swiftProjects/TDDomain/Sources/DomainAssembly.swift(DI 등록)diff 예시
// ToduckCalendarViewModel.swift - private let fetchScheduleListUseCase: FetchServerScheduleListUseCase + private let fetchAllSchedulesUseCase: FetchAllSchedulesUseCase ... - init( - fetchScheduleListUseCase: FetchServerScheduleListUseCase, + init( + fetchAllSchedulesUseCase: FetchAllSchedulesUseCase, finishScheduleUseCase: FinishScheduleUseCase, deleteScheduleUseCase: DeleteScheduleUseCase ) { - self.fetchScheduleListUseCase = fetchScheduleListUseCase + self.fetchAllSchedulesUseCase = fetchAllSchedulesUseCase // ToduckCalendarCoordinator.swift - let fetchScheduleListUseCase = injector.resolve(FetchServerScheduleListUseCase.self) + let fetchAllSchedulesUseCase = injector.resolve(FetchAllSchedulesUseCase.self) ... let toduckCalendarViewModel = ToduckCalendarViewModel( - fetchScheduleListUseCase: fetchScheduleListUseCase, + fetchAllSchedulesUseCase: fetchAllSchedulesUseCase, finishScheduleUseCase: finishScheduleUseCase, deleteScheduleUseCase: deleteScheduleUseCase )Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift (4)
242-253: 버그: 딕셔너리 키 접근 시selectedDate와selectedDate.normalized혼용다른 곳에서는
selectedDate.normalized로 접근하지만 여기서는 생 날짜(selectedDate)를 키로 사용합니다. 키 불일치로 삭제가 반영되지 않는 문제가 발생할 수 있습니다(표시 잔존).수정(diff):
private func removeEventFromWeeklyList(eventId: Int, isSchedule: Bool) { if isSchedule { - if let events = weeklyScheduleList[selectedDate] { - weeklyScheduleList[selectedDate] = events.filter { $0.id != eventId } + if let events = weeklyScheduleList[selectedDate.normalized] { + weeklyScheduleList[selectedDate.normalized] = events.filter { $0.id != eventId } } } else { - if let events = weeklyRoutineList[selectedDate] { - weeklyRoutineList[selectedDate] = events.filter { $0.id != eventId } + if let events = weeklyRoutineList[selectedDate.normalized] { + weeklyRoutineList[selectedDate.normalized] = events.filter { $0.id != eventId } } } unionTodoListForSelectedDate(selectedDate: selectedDate) }
166-173: 로컬 캘린더 일정은 읽기 전용: 체크박스 처리에서 서버 일정만 완료 처리하도록 가드 필요현재
finishTodo→finishSchedule경로에서 로컬 캘린더 일정도.schedule로 분기되어 서버 완료 API를 타게 될 수 있습니다(UX에서 막더라도 방어 필요).Schedule.source == .server가드를 두어 안전하게 무시하세요.수정(diff):
- private func finishTodo(with todo: any TodoItem) async { - if todo.eventMode == .schedule { - await finishSchedule(with: todo) + private func finishTodo(with todo: any TodoItem) async { + if todo.eventMode == .schedule { + await finishSchedule(with: todo) } else { await finishRoutine(with: todo) } } - private func finishSchedule(with todo: any TodoItem) async { - do { - try await finishScheduleUseCase.execute( - scheduleId: todo.id ?? 0, - isComplete: !todo.isFinished, - queryDate: selectedDate.convertToString(formatType: .yearMonthDay) - ) + private func finishSchedule(with todo: any TodoItem) async { + guard let schedule = todo as? Schedule, schedule.source == .server, let scheduleId = schedule.id else { + return // 로컬 캘린더 또는 유효하지 않은 ID인 경우 무시 + } + do { + try await finishScheduleUseCase.execute( + scheduleId: scheduleId, + isComplete: !schedule.isFinished, + queryDate: selectedDate.convertToString(formatType: .yearMonthDay) + ) output.send(.successFinishTodo) } catch { output.send(.failure(error: "일정을 완료할 수 없습니다.")) } }Also applies to: 174-185
256-263: 로컬 일정은 '내일로 미루기' 대상에서 제외 필요로컬 캘린더는 읽기 전용이므로 삭제/생성으로 “미루기”를 구현하면 서버 API 오용이 됩니다. 서버 일정만 동작하도록 가드를 추가하세요.
수정(diff):
private func handleMoveToTomorrow(todoId: Int, event: any TodoItem) { let isSchedule = event.eventMode == .schedule let isAllDay = event.isAllDay - - deleteEvent(todoId: todoId, isSchedule: isSchedule, isOneDayDeleted: true) - updateEventToNextDay(todoId: todoId, isSchedule: isSchedule, isAllDay: isAllDay) + // 로컬 캘린더 일정은 무시 + if isSchedule, let s = event as? Schedule, s.source != .server { return } + + deleteEvent(todoId: todoId, isSchedule: isSchedule, isOneDayDeleted: true) + updateEventToNextDay(todoId: todoId, isSchedule: isSchedule, isAllDay: isAllDay) }
264-280: '내일로 미루기' 생성 시 서버 생성语义 보장 위해 id 초기화 필요
CreateScheduleUseCase는 생성语义로 보입니다. 기존id를 유지한 채 생성하면 업데이트/중복 문제 소지가 있습니다. 또한 로컬 일정 가드 추가가 필요합니다.수정(diff):
private func updateEventToNextDay(todoId: Int, isSchedule: Bool, isAllDay: Bool) { guard let event = getEvent(for: todoId, isAllDay: isAllDay) else { return } let nextDay = getNextDay(from: selectedDate) if isSchedule { - if let schedule = event as? Schedule { + if var schedule = event as? Schedule, schedule.source == .server { - var updatedSchedule = schedule - updatedSchedule.startDate = nextDay - updatedSchedule.endDate = nextDay - Task { await createSchedule(todoId: todoId, schedule: updatedSchedule) } + // 생성语义를 명확히: 새 엔티티로 간주되도록 id 초기화 + schedule.id = nil + schedule.startDate = nextDay + schedule.endDate = nextDay + Task { await createSchedule(todoId: todoId, schedule: schedule) } } } else { if let routine = event as? Routine { Task { await createRoutine(with: todoId, routine: routine) } } } }
🧹 Nitpick comments (34)
Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift (2)
45-47: 권한 프롬프트를 탭바 구성 직후 연속으로 노출 — UX 및 책임 위치 재검토 제안탭바 세팅 직후 푸시→캘린더 권한을 연달아 요청합니다. 첫 진입 UX에서 OS 팝업 2개가 연속으로 뜨는 건 이탈을 유발할 수 있어, 기능 문맥(예: 알림/캘린더 기능 진입 시) 또는 사전 안내 화면 이후에 순차적으로 요청하는 방식을 권장합니다. 또한 권한 오케스트레이션은 Coordinator보다는 공통 PermissionManager(TDCore) 또는 각 기능의 UseCase/Storage에서 필요 시점에 요청하도록 위임하면 책임 분리가 명확해집니다.
90-92:@unknown default사용 권장미래의 enum 케이스 추가 시 컴파일러 경고를 받기 위해
default대신@unknown default를 사용해주세요. 위의 iOS17 대응 리팩터에 포함되어 있습니다.Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift (2)
3-17: Calendar.current 대신 autoupdatingCurrent 사용 + default 방어 보강 제안사용자 설정(지역/캘린더) 변경에 자동으로 따라가려면
Calendar.autoupdatingCurrent가 더 안전합니다. 또한.weekday는 1~7로 보장되므로default는 실행되지 않는 것이 정상입니다. 디버그에서만 검출하도록 assertion을 추가해두면 유지보수에 도움이 됩니다.아래처럼 미세 수정 제안드립니다:
extension Date { func weekdayEnum() -> TDWeekDay { - let weekday = Calendar.current.component(.weekday, from: self) + let weekday = Calendar.autoupdatingCurrent.component(.weekday, from: self) switch weekday { case 1: return .sunday case 2: return .monday case 3: return .tuesday case 4: return .wednesday case 5: return .thursday case 6: return .friday case 7: return .saturday - default: return .monday + default: + assertionFailure("Unexpected weekday component: \(weekday)") + return .monday } } }
4-4: 네이밍 경미 제안:weekdayEnum()→tdWeekday또는weekDay()반환 타입이 이미 enum이므로 메서드명에
Enum을 중복 표기하지 않아도 의미가 충분합니다. 네이밍만 개선 범위라 선택사항으로 남깁니다.Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift (1)
6-15: EventKit 타입(EKEvent) 노출로 계층 간 결합도 상승 — Data 계층 외부 노출 최소화 권장Storage 프로토콜이
EKEvent를 그대로 반환하면 상위(Repository/Domain/Presentation)로 SDK 타입이 전파됩니다. 장기적으로 테스트 용이성/이식성/교체 가능성에 불리합니다. Storage 구현(TDStorage) 내부에서만 EventKit을 캡슐화하고, Data 계층에는 순수 DTO 또는 도메인에 필요한 최소 필드로 구성된 POJO/struct를 돌려주는 형태가 바람직합니다.예시(개념 스케치): TDData에 DTO를 두고 Storage는 이를 반환
-public protocol ScheduleStorage { - func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] -} +public struct LocalCalendarEventDTO { + public let identifier: String + public let title: String + public let startDate: Date + public let endDate: Date + public let isAllDay: Bool + public let location: String? + public let notes: String? +} + +public protocol ScheduleStorage { + func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [LocalCalendarEventDTO] +}Repository에서 DTO→
Schedule매핑을 수행하면 모듈 간 의존을 깔끔하게 유지할 수 있습니다.Projects/TDStorage/Sources/StorageAssembly.swift (1)
21-23: ScheduleStorage 수명 범위(Container 싱글톤) 권장 — EKEventStore 재사용
EKEventStore는 비용이 큰 편이라 인스턴스 재사용이 권장됩니다. 현재 등록은 기본(transient) 범위로 보이며, 해석 시마다 새로운ScheduleStorageImpl이 생길 수 있습니다. 컨테이너 범위 싱글톤으로 지정해 주세요.- container.register(ScheduleStorage.self) { _ in - ScheduleStorageImpl() - } + container.register(ScheduleStorage.self) { _ in + ScheduleStorageImpl() + } + .inObjectScope(.container)Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift (1)
58-60:source: .server매핑 추가 LGTM — 완료 여부 파생 계산 고려
- 서버 소스 명시(
.server)와records전달 모두 적절합니다.개선 제안:
- 현재
isFinished가 항상false인데, 레코드 완결 상태로 파생 계산하면 일관성이 좋아집니다. 예:let isFinished = !records.isEmpty && records.allSatisfy(\.isComplete)그 후
isFinished: isFinished로 설정.
- 서버가 레코드를 빈 배열로 보낼 때,
scheduleRecords를nil로 두고 싶다면records.isEmpty ? nil : records로 메모리/전달량을 약간 절약할 수 있습니다.Projects/toduck/SupportingFiles/Info.plist (1)
62-63: 권한 문구 지역화 권장
Info.plist의 Privacy 문구는 런타임 프롬프트에 그대로 노출됩니다. 최소ko/en로컬라이제이션을InfoPlist.strings로 제공해 주세요.예시:
/* Base.lproj/InfoPlist.strings */ "NSCalendarsUsageDescription" = "To show your iOS Calendar events together with your To.duck schedules, the app needs access to your calendars."; /* ko.lproj/InfoPlist.strings */ "NSCalendarsUsageDescription" = "사용자의 iOS 캘린더 일정을 앱에서 조회하여 서버 일정과 함께 보여주기 위해 접근 권한이 필요합니다.";Projects/TDStorage/Sources/ScheduleStorageImpl.swift (3)
9-22: 권한 요청 흐름 보완 및 async 사용 정당화 필요현재
.authorized가 아니면 곧바로permissionDenied를 던집니다. 초회 진입(.notDetermined) 시엔 권한 요청을 시도한 뒤 결과에 따라 진행 또는 에러를 던지는 편이 UX/제어 흐름상 자연스럽고, 메서드가async인 이유도 명확해집니다.다음처럼
fetchEvents내에서 권한 보장을 선행하고, 비동기 요청을 통해 async 사용을 정당화하는 것을 제안합니다.- func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] { - guard EKEventStore.authorizationStatus(for: .event) == .authorized else { - throw TDDataError.permissionDenied - } - + func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] { + try await ensureAuthorization() + let predicate = eventStore.predicateForEvents( withStart: startDate, end: endDate, calendars: nil ) let events = eventStore.events(matching: predicate) return events }그리고 본 메서드 밖(동일 타입 내부)에 다음 보조 함수를 추가해 주세요:
// 권한이 미결정이면 요청하고, 거부 상태면 에러를 던집니다. private func ensureAuthorization() async throws { let status = EKEventStore.authorizationStatus(for: .event) if status == .authorized { return } if status == .notDetermined { // completion 핸들러 기반 API를 async로 변환 let granted: Bool = try await withCheckedThrowingContinuation { cont in eventStore.requestAccess(to: .event) { granted, error in if let error { cont.resume(throwing: error) } else { cont.resume(returning: granted) } } } if granted { return } } throw TDDataError.permissionDenied }추가로, 입력 유효성(예:
startDate > endDate)에 대한 빠른 가드 처리도 고려해 주세요. 잘못된 기간으로 인한 불필요한 쿼리를 방지할 수 있습니다.
7-7: 테스트 용이성 향상을 위한 의존성 주입 고려
EKEventStore를 직접 생성하면 단위 테스트가 어려워집니다. 생성자 주입(기본값은 실제 인스턴스)으로 바꾸면 모킹이 쉬워집니다.다음과 같이 변경을 제안합니다.
-final class ScheduleStorageImpl: ScheduleStorage { - private let eventStore = EKEventStore() +final class ScheduleStorageImpl: ScheduleStorage { + private let eventStore: EKEventStore + + init(eventStore: EKEventStore = EKEventStore()) { + self.eventStore = eventStore + }
14-21: 캘린더 필터링 옵션 제공 제안현재
calendars: nil로 모든 캘린더를 포함합니다. 구독/읽기전용/생일 등 특수 캘린더 포함 여부는 제품 정책에 따라 달라질 수 있으므로, 향후 요구사항 변화에 대비해 캘린더 필터(예: 표시 중 캘린더만, 특정 소스만 등)를 주입 가능한 옵션으로 노출하는 것을 권장합니다.Projects/TDData/Sources/DataAssembly.swift (1)
69-77: Swinject 클로저 내부에서는 resolver를 사용하세요등록 클로저에서 외부
container를 캡쳐하여resolve를 호출하고 있습니다. Swinject 권장 패턴은 클로저 인자로 전달되는resolver를 사용하는 것입니다. 순환 참조/예기치 않은 의존성으로부터 안전하고, 테스트 대체도 수월합니다.다음과 같이 미세 수정이 가능합니다.
- container.register(ScheduleRepository.self) { _ in - guard let service = container.resolve(ScheduleService.self) else { + container.register(ScheduleRepository.self) { resolver in + guard let service = resolver.resolve(ScheduleService.self) else { fatalError("ScheduleService is not registered") } - guard let storage = container.resolve(ScheduleStorage.self) else { + guard let storage = resolver.resolve(ScheduleStorage.self) else { fatalError("ScheduleStorage is not registered") } return ScheduleRepositoryImpl(service: service, storage: storage) }.inObjectScope(.container)Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift (3)
5-5: SwiftLint 경고(type_name) 해소를 위한 테스트 클래스명 단축
FetchLocalCalendarScheduleListUseCaseTests길이가 40자를 초과하여 경고가 발생합니다. 의미를 유지하며 짧게 줄여 주세요.-final class FetchLocalCalendarScheduleListUseCaseTests: XCTestCase { +final class LocalCalendarScheduleListUseCaseTests: XCTestCase {
43-45: 타임존/로케일 고정으로 테스트 결정론 강화문자열 날짜 파싱 시 환경별 타임존/로케일에 따라 오차가 날 수 있습니다.
DateFormatter와Calendar에 동일한 타임존을 명시하면 테스트가 견고해집니다.- let calendar = Calendar.current - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.current + let dateFormatter = DateFormatter() + dateFormatter.calendar = calendar + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = calendar.timeZone + dateFormatter.dateFormat = "yyyy-MM-dd"
55-59: 검증 강화: 8/21의 일정 제목까지 체크개수 외에 실제 제목 집합을 검증하면 회귀를 더 잘 포착할 수 있습니다(순서 비의존).
- XCTAssertEqual(result[day21Key]?.count, 2, "8월 21일에는 2개의 일정이 있어야 합니다.") + XCTAssertEqual(result[day21Key]?.count, 2, "8월 21일에는 2개의 일정이 있어야 합니다.") + let titles21 = Set(result[day21Key]?.map { $0.title } ?? []) + XCTAssertEqual(titles21, Set(["치과 예약", "여름 휴가"]))Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
96-154: 딕셔너리 빌드 시 단일+반복의 기간 확장 처리 적절
effectiveEndDate를 쿼리 종료일로 확장하여 반복 일정을 기간 전반에 투영하는 처리가 합리적입니다. 다만 UI가 “해당 날짜의 구체적인 표시”를 원한다면, 클론 객체의startDate/endDate를 해당 날짜 문자열로 덮어씌우는 전략도 고려 여지 있습니다(현재는 원본 기간 유지).아래와 같이 선택적으로 표시용 필드를 덮어쓰는 방식을 도입할 수 있습니다(도메인 영향도 검토 필요).
// createClonedSchedule 내부 예시(표시용으로만 날짜 덮어쓰기) let displayDateStr = date.toString(format: .yearMonthDay) return Schedule( id: schedule.id, title: schedule.title, category: schedule.category, startDate: displayDateStr, // 표시 기준일 endDate: displayDateStr, // 표시 기준일 ... source: .server )이 변경은 다운스트림(정렬/표시/상세 화면) 요구사항과 충돌 소지가 있으니, 실제 소비자(ViewModel/UI) 기대치 확인이 선행되어야 합니다.
Projects/TDDomain/Tests/Extension/Schedule+Init.swift (1)
4-21: 테스트 헬퍼 이니셜라이저에 source 주입 가능 옵션 추가 제안현재
Schedule테스트 이니셜라이저가source: .server로 고정되어 있어 로컬 캘린더(.localCalendar) 시나리오에 대한 테스트 작성이 불편합니다.source파라미터를 기본값.server로 열어두면 테스트 가독성과 재사용성이 좋아집니다.적용 예시(diff):
- init(id: Int?, title: String, startDate: String, endDate: String, repeatDays: [TDWeekDay]? = nil, scheduleRecords: [ScheduleRecord]? = nil) { + init( + id: Int?, + title: String, + startDate: String, + endDate: String, + repeatDays: [TDWeekDay]? = nil, + scheduleRecords: [ScheduleRecord]? = nil, + source: Schedule.Source = .server + ) { self.init( id: id, title: title, category: TDCategory(colorHex: "FFFFFF", imageName: "default"), startDate: startDate, endDate: endDate, isAllDay: true, time: nil, repeatDays: repeatDays, alarmTime: nil, place: nil, memo: nil, isFinished: false, scheduleRecords: scheduleRecords, - source: .server + source: source ) }Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (2)
20-31: 로컬 캘린더 권한 거부 등 부분 실패를 전체 실패로 전파하는 정책 재고EventKit 권한 거부/제한(EKError) 등으로 로컬만 실패해도 전체가 throw 됩니다. UX 측면에서 서버 데이터는 보여주고 로컬만 비워두는 편이 자연스럽습니다. 정책적으로 허용된다면 로컬 실패를 무시(로그만 남김)하는 경로를 고려해주세요.
원하시면 실패 케이스 분기와 로깅까지 반영한 패치 제안 드리겠습니다.
예시(diff):
- let (serverSchedules, localSchedules) = try await (serverSchedulesTask, localSchedulesTask) + let serverSchedules = try await serverSchedulesTask + let localSchedules = (try? await localSchedulesTask) ?? [:] // 로컬 실패 시 빈 결과
4-15: 동시성 안전성(Sendable) 및 문서화 보완 제안UseCase가 광범위하게 비동기 컨텍스트에서 주입/호출될 가능성이 크므로
FetchAllSchedulesUseCase/FetchAllSchedulesUseCaseImpl에Sendable을 부여하면 안전성이 향상됩니다(내부 의존성이 Sendable 가능할 때).예시(diff):
-public protocol FetchAllSchedulesUseCase { +public protocol FetchAllSchedulesUseCase: Sendable {-public final class FetchAllSchedulesUseCaseImpl: FetchAllSchedulesUseCase { +public final class FetchAllSchedulesUseCaseImpl: FetchAllSchedulesUseCase, @unchecked Sendable {Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift (1)
200-207: 원자적 동작 고려: 삭제 후 생성 실패 시 복구 전략현재 흐름은 삭제 → 생성 순으로 진행됩니다. 생성이 실패하면 이벤트가 사라진 상태가 됩니다. 가능하다면 생성 성공 확인 후 삭제하는 순서 또는 실패 시 주간 리스트에 원상 복구(보상 트랜잭션)를 고려해주세요.
현 UI/도메인 정책상 삭제·생성의 원자성이 요구되는지 확인 부탁드립니다. 필요 시 보상 로직 제안 드리겠습니다.
Also applies to: 256-263
Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift (2)
170-173: 시간대 의존성 최소화를 위한stripTime()보정 제안
DateFormatter.yyyymmdd는 GMT(UTC)로 파싱하지만,stripTime()은Calendar.current를 사용합니다. 로컬 타임존 설정에 따라 경계 시각에서 날짜 키가 어긋날 소지가 있습니다. 테스트 안정성을 위해 UTC 그레고리력으로 고정하세요.수정(diff):
private extension Date { /// 00:00:00 로 통일하여 dictionary key 비교 시 시간 차이를 제거 - func stripTime() -> Date { Calendar.current.startOfDay(for: self) } + func stripTime() -> Date { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + return cal.startOfDay(for: self) + } }
5-23: [제안]FetchAllSchedulesUseCase통합 테스트 추가아래 사항을 확인했습니다:
FetchAllSchedulesUseCase에 대한 테스트 파일이 아직 존재하지 않습니다.
(실행한 스크립트:rg -nP 'FetchAllSchedulesUseCase' -g '!**/Pods/**' -C2결과)다음과 같은 주요 시나리오를 검증하는 통합 테스트 추가를 권장드립니다:
- 서버 성공 + 로컬 캘린더 권한 거부(EK 권한 실패)
- 서버 성공 + 로컬에 일정이 전혀 없을 때
- 서버 실패 + 로컬에서 정상적으로 일정 조회
- 서버/로컬 양쪽에 중복된 날짜의 일정이 있을 때 병합 로직 동작
- 에러(throw) 전파 및 예외 처리 경로
테스트 위치 예시:
- 신규 파일:
Projects/TDDomain/Tests/FetchAllSchedulesUseCaseTests.swift- 대상 클래스:
Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCaseImpl.swift원하시면 위 시나리오를 기반으로 한 테스트 스켈레톤 코드를 제공해 드릴 수 있습니다.
Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift (2)
114-136: 중복 객체 재생성 로직 축소 제안(가독성 개선)
isFinished토글 시Schedule전체를 매번 재생성하고 있는데, 헬퍼를 도입하면 중복을 줄이고 실수를 예방할 수 있습니다.예시(diff):
+ @inline(__always) + private func withIsFinished(_ s: Schedule, _ flag: Bool) -> Schedule { + Schedule( + id: s.id, title: s.title, category: s.category, + startDate: s.startDate, endDate: s.endDate, + isAllDay: s.isAllDay, time: s.time, repeatDays: s.repeatDays, + alarmTime: s.alarmTime, place: s.place, memo: s.memo, + isFinished: flag, scheduleRecords: s.scheduleRecords, source: .server + ) + } ... - var updatedSchedule = schedules[index] - updatedSchedule = Schedule( - ... - isFinished: toggledFinished, - scheduleRecords: updatedSchedule.scheduleRecords, - source: .server - ) + let updatedSchedule = withIsFinished(schedules[index], toggledFinished) ... - var updated = currentDayScheduleList[index] - updated = Schedule( - ... - isFinished: toggledFinished, - scheduleRecords: updated.scheduleRecords, - source: .server - ) - currentDayScheduleList[index] = updated + currentDayScheduleList[index] = withIsFinished(currentDayScheduleList[index], toggledFinished)Also applies to: 138-157
166-177: 삭제 후 화면 상태 동기화 방식 확인 요청삭제 시
.deletedTodo만 방출하고 내부monthScheduleDict/currentDayScheduleList는 갱신하지 않습니다. 상위에서 재페치가 즉시 트리거된다면 괜찮지만, 그렇지 않다면 완료 처리처럼 인메모리에서도 제거하는 편이 즉각적인 피드백에 유리합니다.필요 시 로컬 상태 제거 패치도 제안 드리겠습니다.
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift (2)
80-93: '미완료' 섹션에 로컬 캘린더 이벤트 전부 포함: 기획 확인 필요현재 로직은 source가 .localCalendar인 모든 이벤트를 '미완료'에 강제 포함합니다. 앱 내에서 로컬 이벤트를 완료 처리할 수 없다면, 사용자가 '미완료' 탭에서 로컬 이벤트를 모두 보게 되는 것이 의도인지 확인이 필요합니다. 로컬 이벤트를 '미완료'에서 제외하거나, 배지/구분 라벨을 부여하는 대안도 고려 가능합니다.
대안(예시):
- '미완료'에서는 서버 이벤트만 노출:
- if schedule.source == .localCalendar { return true } + if schedule.source == .localCalendar { return false }
- 또는 로컬 이벤트는 별도 섹션/필터로 분리(제품 결정에 따름).
62-103: 메인 스레드에서 UI 상태 업데이트 보장 위해@MainActor추가 권장
fetchScheduleList()내에서 상태 변경 및output.send가 실행되므로, 메인 스레드 보장을 위해@MainActor를 적용하시길 권장드립니다.
- 파일:
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift- 위치:
private func fetchScheduleList() async { … }선언부제안 사항 (택1):
- 메서드 단위 적용
- private func fetchScheduleList() async { + @MainActor + private func fetchScheduleList() async { do { … } catch { … } }
- 클래스 전체 적용
- final class ToduckViewModel: BaseViewModel { + @MainActor + final class ToduckViewModel: BaseViewModel { … }Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift (1)
7-9: 계약(Contract) 명세 보강 제안서버/로컬 각각의 메소드가 반환하는 기간 포함 여부(양끝 포함/제외), 타임존 기준, 정렬 보장 여부를 문서화하면 호출측 오해를 줄일 수 있습니다. 특히 inclusive 여부는 도메인 전반에 영향이 큽니다.
API Doc 주석 예시:
/// - Parameters: /// - startDate: "yyyy-MM-dd", inclusive (local timezone) /// - endDate: "yyyy-MM-dd", inclusive (local timezone) /// - Returns: 정렬 보장 없음. Call site에서 정렬 필요.Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift (2)
14-24: 그룹화 반환 형태 일관성 OK + 범위 한정 검토로컬 이벤트를 [Date: [Schedule]]로 묶어 반환하는 형태가 서버 측과 일관적입니다. 다만 repository가 기간 필터를 보장하지 않는 경우를 대비해 execute 내에서 한 번 더 기간 필터링을 적용하는 것을 고려해볼 수 있습니다(방어적 프로그래밍).
필터 예시(아이디어):
// execute 내부에서 schedules 수신 후: let filtered = schedules.filter { s in guard let s = Date.convertFromString(s.startDate, format: .yearMonthDay), let e = Date.convertFromString(s.endDate, format: .yearMonthDay), let qs = Date.convertFromString(startDate, format: .yearMonthDay), let qe = Date.convertFromString(endDate, format: .yearMonthDay) else { return false } return e >= qs && s <= qe } return groupSchedulesByDay(schedules: filtered)
7-8: SwiftLint 경고(type_name) 처리클래스명 길이 41자로 경고가 발생합니다. 리네이밍이 부담되면 라인 단위로 룰을 비활성화하세요.
적용 예시:
+// swiftlint:disable:next type_name public final class FetchLocalCalendarScheduleListUseCaseImpl: FetchLocalCalendarScheduleListUseCase {또는 이름을 FetchLocalCalendarSchedulesUseCaseImpl로 축약(38자).
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift (1)
190-195: Composite UseCase는 DI에서 직접 resolve하는 편이 낫습니다VC에서 조립하면 구성 지식이 UI로 새어 나옵니다. DomainAssembly/DI에 FetchAllSchedulesUseCase 등록 후 여기서는 한 줄 resolve가 유지 보수에 유리합니다.
적용 예시:
- let fetchServerScheduleListUseCase = DIContainer.shared.resolve(FetchServerScheduleListUseCase.self) - let fetchLocalCalendarScheduleListUseCase = DIContainer.shared.resolve(FetchLocalCalendarScheduleListUseCase.self) - let fetchAllSchedulesUseCase = FetchAllSchedulesUseCaseImpl( - serverUseCase: fetchServerScheduleListUseCase, - localUseCase: fetchLocalCalendarScheduleListUseCase - ) + let fetchAllSchedulesUseCase = DIContainer.shared.resolve(FetchAllSchedulesUseCase.self)Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (2)
34-51: 반환 타입 모듈 접두사 제거 제안동일 모듈 내 타입을 명시적으로 TDDomain.Schedule로 표기할 필요는 없어 보입니다. 일관성을 위해 Schedule로 통일을 권장합니다(선택).
적용 예시:
- func fetchSchedule() async throws -> TDDomain.Schedule { + func fetchSchedule() async throws -> Schedule { return Schedule( id: 999, title: "Mock 단일 일정", ... source: .server ) }
13-15: 사용되지 않는 '내일로 이동' 관련 잔재 정리(선택)프로덕션 프로토콜에서 moveTomorrowSchedule가 제거되었으므로, 관련 플래그/메서드는 테스트 노이즈를 유발할 수 있습니다. 더 이상 사용하지 않으면 정리해주세요.
Also applies to: 63-66
Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift (2)
2-2: EventKit 의존성 도입은 타겟에 적합합니다. CryptoKit import(선택) 및 플랫폼 가드 고려
- iOS 전용 모듈이라면 현재 그대로 충분합니다. 만약 테스트 타겟/미래 플랫폼 확장을 염두에 두신다면
#if canImport(EventKit)가드를 고려해 주세요.- 하단 코멘트(라인 48-66)에서 제안하는 “안정적 로컬 이벤트 ID” 개선을 수용하실 경우 CryptoKit이 필요합니다. 그때는 아래와 같이 import를 추가해 주세요.
-import EventKit +import EventKit +import CryptoKit
28-46: 날짜 범위 계산 안정화: 역순 입력 방지 및 경계 포함 규칙을 코드로 명시
- 현재 구현은 [startOfDay(startDate), startOfDay(endDate)+1일) 형태의 반개구간으로 잘 설계되어 있습니다.
- 추가로, 역순 입력(예: startDate > endDate) 시가 방어되지 않아 스토리지로 비정상 범위가 내려갈 수 있습니다. UI/UseCase에서 이미 보장하더라도 여기서 한 번 더 방어하면 추후 회귀를 막을 수 있습니다.
제안 diff:
- guard let startDay = Date.convertFromString(startDate, format: format), - let endDay = Date.convertFromString(endDate, format: format) else { + guard let startDay = Date.convertFromString(startDate, format: format), + let endDay = Date.convertFromString(endDate, format: format) else { throw TDDataError.convertDTOFailure } + // 역순 입력 방어: 비정상 범위는 조용히 무시하거나(빈 배열) 에러로 승격할 수 있습니다. + if startDay > endDay { + return [] + }
- 캘린더는
Calendar.current를 사용 중인데, 문자열 파싱 포맷이 Gregorian(예: "yyyy-MM-dd") 기반이라면Calendar(identifier: .gregorian)사용으로 일관성을 강제하는 것도 옵션입니다. 다만, 실사용 환경(iOS, 한국 로케일)에서는 현재 구현으로도 문제가 발생할 가능성은 낮습니다.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (30)
Projects/TDCore/Sources/Error/TDDataError.swift(2 hunks)Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift(1 hunks)Projects/TDData/Sources/DataAssembly.swift(1 hunks)Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift(1 hunks)Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift(1 hunks)Projects/TDDomain/Sources/DomainAssembly.swift(1 hunks)Projects/TDDomain/Sources/Entity/Schedule.swift(4 hunks)Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift(1 hunks)Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift(1 hunks)Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift(1 hunks)Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleUseCase.swift(0 hunks)Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift(3 hunks)Projects/TDDomain/Sources/UseCase/Schedule/MoveTomorrowScheduleUseCase.swift(0 hunks)Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift(1 hunks)Projects/TDDomain/Tests/Extension/Schedule+Init.swift(1 hunks)Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift(1 hunks)Projects/TDDomain/Tests/FetchScheduleListUseCaseTests.swift(0 hunks)Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift(1 hunks)Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift(3 hunks)Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift(1 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift(1 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift(4 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift(1 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift(2 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift(4 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift(3 hunks)Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift(2 hunks)Projects/TDStorage/Sources/ScheduleStorageImpl.swift(1 hunks)Projects/TDStorage/Sources/StorageAssembly.swift(1 hunks)Projects/toduck/SupportingFiles/Info.plist(1 hunks)
💤 Files with no reviewable changes (3)
- Projects/TDDomain/Sources/UseCase/Schedule/MoveTomorrowScheduleUseCase.swift
- Projects/TDDomain/Tests/FetchScheduleListUseCaseTests.swift
- Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleUseCase.swift
🧰 Additional context used
🧬 Code graph analysis (15)
Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift (2)
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
execute(15-24)Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift (1)
execute(14-24)
Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift (1)
Projects/TDStorage/Sources/ScheduleStorageImpl.swift (1)
fetchEvents(9-23)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift (2)
Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (1)
execute(20-32)Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
execute(15-24)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift (1)
Projects/TDCore/Sources/Dependency Injection/DIContainer.swift (1)
resolve(35-40)
Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift (1)
Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (2)
fetchServerScheduleList(24-27)fetchLocalCalendarScheduleList(29-32)
Projects/TDData/Sources/DataAssembly.swift (2)
Tuist/ProjectDescriptionHelpers/TargetDependency+Module.swift (1)
storage(30-32)Projects/TDCore/Sources/Dependency Injection/DIContainer.swift (1)
resolve(35-40)
Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift (4)
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
execute(15-24)Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (1)
fetchLocalCalendarScheduleList(29-32)Projects/TDCore/Sources/Extension/Date/Date+DateFormatType.swift (1)
convertFromString(78-82)Projects/TDCore/Sources/Extension/Date/Date+StartEndDay.swift (1)
startOfDay(30-32)
Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift (2)
Projects/TDCore/Sources/Notification/LocalPushNotificationManager.swift (1)
requestAuthorization(8-14)Projects/TDCore/Sources/Log/TDLogger.swift (2)
error(18-23)info(11-16)
Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (1)
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
execute(15-24)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift (2)
Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (1)
execute(20-32)Projects/TDCore/Sources/Extension/Date/Date+TimeSortHelper.swift (1)
timeSortKey(4-16)
Projects/TDDomain/Sources/DomainAssembly.swift (1)
Projects/TDCore/Sources/Dependency Injection/DIContainer.swift (1)
resolve(35-40)
Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift (4)
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (1)
execute(15-24)Projects/TDCore/Sources/Extension/Date/Date+DateFormatType.swift (1)
convertFromString(78-82)Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift (1)
weekdayEnum(4-16)Projects/TDCore/Sources/Extension/Date/Date+StartEndDay.swift (1)
startOfDay(30-32)
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (3)
Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (1)
execute(20-32)Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift (1)
execute(14-24)Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (1)
fetchServerScheduleList(24-27)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift (1)
Projects/TDCore/Sources/Dependency Injection/DIContainer.swift (1)
resolve(35-40)
Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (2)
Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift (1)
createSchedule(69-86)Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift (2)
createSchedule(266-290)updateSchedule(164-177)
🪛 SwiftLint (0.57.0)
Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift
[Warning] 5-5: Type name 'FetchLocalCalendarScheduleListUseCaseTests' should be between 3 and 40 characters long
(type_name)
Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift
[Warning] 7-7: Type name 'FetchLocalCalendarScheduleListUseCaseImpl' should be between 3 and 40 characters long
(type_name)
🔇 Additional comments (25)
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift (1)
285-287: Schedule 초기화 시source:파라미터 누락 방지 조치 필요
Schedule생성자 호출 중 다수에서source:를 지정하지 않아, 기본값 또는 의도치 않은 소스가 설정될 수 있습니다. 또한, 로컬 캘린더(.localCalendar) 일정에 대해 수정이 시도되지 않도록updateSchedule()초입에 방어 로직을 추가하는 것을 권장드립니다.다음 위치들을 확인해 주세요:
- TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift
• 117행updatedSchedule = Schedule(
• 140행updated = Schedule(- TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift
• 272행let schedule = Schedule(- TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift
• 165행return Schedule(- TDData/Sources/Repository/ScheduleRepositoryImpl.swift
• 50행return Schedule(- TDData/Sources/DTO/ScheduleListResponseDTO.swift
• 45행return Schedule(- 테스트 코드 전반 (TDDomain/Tests 및 TDDomain/Tests/Mock)
•Schedule(...)호출부조치 사항:
- 위 위치들에서
Schedule(..., source: .localCalendar | .server | ...)형태로 명시적으로source:를 추가- 테스트 환경에서는 적절한 기본
source:값을 지정하거나, 생성자 시그니처를 수정해 기본값을 정의TodoCreatorViewModel.updateSchedule()초입에 로컬 캘린더 방어 로직 추가private func updateSchedule() async { do {
// 로컬 캘린더 일정은 앱 내 편집 불가if let schedule = preEvent as? Schedule, schedule.source == .localCalendar {TDLogger.error("로컬 캘린더 일정은 읽기 전용입니다.")output.send(.failureAPI("로컬 캘린더 일정은 읽기 전용입니다."))return} guard let scheduleId = preEvent?.id else { return }위 내용들을 검토·반영해 주시기 바랍니다.
Projects/TDDomain/Sources/Entity/Schedule.swift (1)
4-7:Schedule.Source및source속성 도입: 출처 추적에 유용 — 도메인 확장성 측면에서 적절서버/로컬 캘린더 출처를 도메인 모델에 포함한 선택이 훌륭합니다. 이후 정책 분기(UI/권한/동기화)에서 명확한 기준이 됩니다.
Also applies to: 23-24, 42-59
Projects/TDCore/Sources/Error/TDDataError.swift (1)
17-17: 권한 분기 및 재사용 현황 확인 완료
TDDataError.permissionDenied는ScheduleStorageImpl.fetchEvents(EventKit)에서만 throw되고, 다른 스토리지나 영역에서는 재사용되지 않습니다.- 프리젠테이션 계층의
MainTabBarCoordinator에서.notDetermined상태를requestAccess로 처리하고 있어, 실제로 호출 시에는.denied혹은.restricted상태에서만permissionDenied가 발생합니다.- 이 흐름을 주석이나 문서로 명시해 두면, 다른 호출부에서
.notDetermined상태가 잘못 전달되지 않도록 안전성을 높일 수 있습니다.추가 제안 (선택 사항):
- 향후
.restricted(자녀 보호 등)와 권한 미요청(.notDetermined)을 별도 에러(permissionRestricted,authorizationRequired)로 세분화하면, 호출측에서 더 세밀한 대응이 가능해집니다.적용 위치:
Projects/TDCore/Sources/Error/TDDataError.swift(case permissionDenied / 메시지)Projects/TDStorage/Sources/ScheduleStorageImpl.swift(fetchEvents 권한 분기)Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift (1)
83-85: 테스트 시그니처 업데이트 적절
Schedule이니셜라이저 변경에 따라scheduleRecords와source: .server를 명시한 부분 일관성 있게 반영되어 있습니다. 동작 영향 없음.Projects/TDData/Sources/DataAssembly.swift (1)
73-76: ScheduleStorage DI 등록 및 ScheduleRepository 의존성 주입 확인 완료
Projects/TDStorage/Sources/StorageAssembly.swift에서container.register(ScheduleStorage.self) { … }등록이 확인되었습니다.Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift에init(service: ScheduleService, storage: ScheduleStorage)이니셜라이저가 올바르게 정의되어 있습니다.Projects/TDData/Sources/DataAssembly.swift에서container.register(ScheduleRepository.self)클로저에storage = container.resolve(ScheduleStorage.self)호출이 포함되어 런타임 오류 없이 주입이 보장됩니다.위 사항으로 DI 설정 누락에 따른 러ntime 오류 가능성은 제거되었습니다. 변경된 코드에 문제가 없으므로 승인합니다.
Projects/TDDomain/Sources/DomainAssembly.swift (2)
316-321: 서버 일정 조회 UseCase 등록 적절합니다
FetchServerScheduleListUseCase→FetchServerScheduleListUseCaseImpl바인딩이 명확하며, 기존ScheduleRepository의존성 재사용도 타당합니다.
323-328: 로컬 캘린더 일정 조회 UseCase 등록 적절합니다서버/로컬 경로 분리가 DI 레벨에서 명확히 드러나 유지보수성이 좋아졌습니다.
Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift (1)
46-48: 그룹화 키 개수 검증 케이스 적절함3일(20·21·22일)만 포함되는지 확인하는 단언은 그룹화 로직의 핵심을 잘 커버합니다. 👍
Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift (3)
15-24: 서버 경로 전환 및 실행 흐름 깔끔합니다
fetchServerScheduleList로의 전환, 필터 → 딕셔너리 빌드 파이프라인이 명확합니다.
26-87: 필터 단계 세분화 로직 타당하나, 경계 조건 재검토 권장네 가지 경우의 수 분기 구조는 명료합니다. 다만 아래 사항은 추후 요구사항에 따라 재검토 여지가 있습니다.
- 단일+반복의 경우 Set 삽입 후 break로 충분(사실상 “해당 범위에 등장 여부” 판정)이지만, 반복 요일이 쿼리 내 여러 번 등장해도 한 번만 포함하는 설계가 의도인지 확인 필요.
Set<Schedule>사용은 중복 제거에 유효하나,Schedule의Hashable동등성 정의가 ID 기준인지 필드 전체 기준인지에 따라 의도치 않은 제거가 일어날 수 있습니다.필요 시
Schedule의hash(into:)/==가 ID 기반인지 확인 부탁드립니다. ID 기반이 아니라면 집합 동작이 변할 수 있습니다.
155-181: source 설정 명시 좋습니다클론 시
source: .server를 명시하여 데이터 출처를 일관되게 보존하는 점이 좋습니다.scheduleRecords를 그대로 전달하되 날짜별 삭제/완료는recordMap으로 필터링하는 구조도 타당합니다.Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift (2)
20-23: 서버/로컬 병렬 페치 패턴 적절함
async let으로 서버/로컬을 동시에 요청하는 전략은 대기 시간을 줄여 UX에 유리합니다. 유지하셔도 좋습니다.
26-31: 리뷰 코멘트 취소: 날짜 키 정규화는 이미 서버/로컬 UseCase에서 처리되고 있습니다.–
FetchServerScheduleListUseCase.swift의 scheduleDict 생성부에서let key = calendar.startOfDay(for: date) scheduleDict[key, default: []].append(cloned)로 키를
startOfDay로 정규화하고 있습니다.
–FetchLocalCalendarScheduleListUseCase.swift에서도let dayKey = calendar.startOfDay(for: currentDate) groupedDictionary[dayKey, default: []].append(schedule)로 동일하게 정규화 중입니다.
따라서
FetchAllSchedulesUseCase에서 추가 정규화 로직을 넣지 않아도, 병합 시 날짜 키는 이미 00:00 기준으로 맞춰져 있어 중복·누락 이슈가 없습니다.Likely an incorrect or invalid review comment.
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift (2)
27-27: 의존성 교체 적절 — 통합 유스케이스 주입으로 VM 책임 축소
FetchAllSchedulesUseCase로 교체하여 ViewModel의 조합 책임을 도메인 계층으로 이전한 방향성 좋습니다.Also applies to: 46-47, 57-57
124-131: 주간 일정/루틴 동시 페치 흐름 적절주간 범위 파라미터로 스케줄/루틴을 각각 가져와 상태에 반영하는 로직은 명료합니다.
Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift (1)
27-43: 서버 일정 유스케이스 커버리지 우수 — 단일/기간/반복/삭제 레코드 모두 검증여러 엣지 케이스를 포괄적으로 테스트하고 있어 회귀 방지에 도움이 됩니다. 고생하셨습니다.
Also applies to: 45-71, 72-92, 94-114, 116-131, 133-155
Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift (1)
23-40: 서버 전용 유스케이스 주입으로 책임 분리 명확 — LGTM캘린더 화면이 서버 데이터만 다루도록 의존성을 명확히 한 점 좋습니다. 이후 로컬 통합 화면과의 역할 분리도 선명해집니다.
Also applies to: 32-40
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift (2)
20-23: FetchAllSchedulesUseCase 의존성 교체 좋습니다단일 유스케이스로 서버+로컬 통합 책임을 위임한 방향성 OK. ViewModel의 역할이 단순해지고 테스트 대역 구성도 쉬워집니다.
Also applies to: 44-49
74-76: 시간 정렬 로직 LGTMDate.timeSortKey를 이용해 종일(0) → 유효 시간 → 기타(Int.max) 순으로 안정적 정렬됩니다. View 표시 일관성에 도움됩니다.
Also applies to: 78-79
Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift (1)
7-9: 서버/로컬 조회 분리 훌륭합니다데이터 소스 분리가 명확해졌고 상위 유스케이스 조합이 깔끔해집니다.
Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift (1)
26-50: 다일 일정 반복 포함 로직(L<=R, inclusive) 적합합니다startOfDay 기준으로 종료일까지 포함시키는 while 조건이 올바릅니다. DST 구간에서도 startOfDay 사용으로 안전합니다.
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift (1)
199-206: 동일 UseCase 인스턴스를 Toduck/Todo에 공유 전달한 점 좋습니다일정 데이터 소스의 일관성을 보장하고, 캐싱/최적화 여지가 생깁니다.
Also applies to: 218-228
Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift (1)
24-32: 새 fetch 메소드(Mock) 구현 OK에러 주입과 기본 리스트 반환이 간결하고 테스트에 충분합니다.
Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift (2)
8-16: Storage 의존성 주입 및 공개 이니셜라이저 설계, 적절합니다
ScheduleStorage를 별도 주입한 점이 SRP와 테스트 용이성을 잘 만족합니다.DataAssembly에서의 wiring 변경과 시그니처 변경이 이미 PR 설명과 일치합니다. LGTM.
23-26: 서버 일정 조회 메서드 네이밍 변경 검증 완료 — 추가 조치 불필요합니다검토해주신 대로 레포지토리 호출부 전반을 확인한 결과,
fetchServerScheduleList(startDate:endDate:)호출부가 도메인, 테스트, UseCase, 구현체 등 모든 위치에서 올바르게 적용되었고- 레포지토리 레벨에서 더 이상
fetchScheduleList를 직접 호출하는 구문은 존재하지 않습니다따라서 메서드 네이밍 변경으로 인한 호출부 불일치는 없으며, 추가 작업이 필요 없습니다.
| private func mapToSchedules(from events: [EKEvent]) -> [Schedule] { | ||
| return events.map { event in | ||
| return Schedule( | ||
| id: event.eventIdentifier.hashValue, | ||
| title: event.title ?? "", | ||
| category: TDCategory(colorHex: "#FFFFFF", imageName: "none"), | ||
| startDate: event.startDate.convertToString(formatType: .yearMonthDay), | ||
| endDate: event.endDate.convertToString(formatType: .yearMonthDay), | ||
| isAllDay: event.isAllDay, | ||
| time: event.isAllDay ? nil : event.startDate.convertToString(formatType: .time24Hour), | ||
| repeatDays: nil, | ||
| alarmTime: nil, | ||
| place: nil, | ||
| memo: nil, | ||
| place: event.location, | ||
| memo: event.notes, | ||
| isFinished: false, | ||
| scheduleRecords: nil | ||
| scheduleRecords: nil, | ||
| source: .localCalendar | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
hashValue 기반 로컬 이벤트 ID는 비결정적입니다 — 안정적/충돌-회피 ID로 교체 필요
event.eventIdentifier.hashValue는 Swift의 구현 상세상 프로세스/빌드마다 바뀔 수 있어, 앱 재시작 후 동일 이벤트를 동일 ID로 식별하지 못할 수 있습니다. 머지/선택/스크롤 포지션 보존/디프 계산에서 문제가 됩니다. 또한 서버 일정(Int ID)과의 충돌 가능성도 배제하기 어렵습니다.- 권장: 이벤트 식별자(String)를 안정적 해시(SHA-256 등)로 변환해 결정적 Int를 만들고, 서버 ID와 충돌을 피하기 위해 음수로 네임스페이스 분리.
제안 diff(해당 라인 교체):
- id: event.eventIdentifier.hashValue,
+ id: makeLocalCalendarId(eventIdentifier: event.eventIdentifier),지도 함수(파일 내 private 영역에 추가):
// CryptoKit 필요: import CryptoKit
private func makeLocalCalendarId(eventIdentifier: String) -> Int {
// 결정적 양의 해시값 생성
let digest = SHA256.hash(data: Data(eventIdentifier.utf8))
let v = digest.prefix(8).reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } // 상위 8바이트 -> 64비트
let positive = Int(truncatingIfNeeded: v & 0x7fffffffffffffff)
// 서버(Int 양수로 가정)와 충돌 회피: 음수 영역으로 강제
return positive > 0 ? -positive : positive
}추가적으로(선택):
- 카테고리 색상:
EKCalendar는cgColor를 노출합니다. 변환 유틸이 있다면 해당 색상으로 매핑해 주면 UX 일관성이 좋아집니다. 없으면 현행 기본값 유지도 OK입니다. - all-day/다일 이벤트: 현재는 단일
startDate/endDate만 설정합니다. 다일 확장(여러 날짜에 분할 표시)은 UseCase/UI에서 처리 중인지 확인해 주세요.
필요하시면 위 변경에 대한 유닛 테스트(같은 eventIdentifier → 항상 같은 ID, 프로세스 간 일관성, 음수 보장)를 작성해 드릴 수 있습니다.
| import Foundation | ||
| import EventKit | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
플랫폼/권한 변화 대응 확인 요청
iOS 17 이후 EventKit 권한 상태 값이 확장되었습니다. 구현부(ScheduleStorageImpl)에서 .authorized만 허용하는지, 읽기 권한이 필요한 시나리오에서 최신 권한 상태들을 충분히 커버하는지 재확인 부탁드립니다.
아래 스캔으로 권한 분기 로직들을 점검해 주세요:
🏁 Script executed:
#!/bin/bash
# EventKit 권한 체크 사용처 점검
rg -nP --type=swift -C2 'EKEventStore\.authorizationStatus\('
rg -nP --type=swift -C2 '\.requestAccess\(\s*to:\s*\.event'Length of output: 1908
iOS 17용 EventKit 권한 분기 로직 보강 필요
iOS 17부터 .authorized가 더 이상 전체 권한(full access)을 대표하지 않고,
– .fullAccess (전체 읽기/쓰기)
– .writeOnly (쓰기 전용)
가 새로 도입되었습니다.
현재 구현에서는 오직 .authorized만 허용하고 있어, iOS 17 기기에서 읽기 권한(full access)을 제대로 검증하지 못합니다.
다음 항목을 반영해 주세요:
• ScheduleStorageImpl.swift (fetchEvents)
– EKEventStore.authorizationStatus(for: .event) 결과를
• iOS 17 이상: .fullAccess
• 그 외(iOS 16 이하): .authorized
두 상태 모두 허용하도록 분기 보강
– 예시(diff):
func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] {
- guard EKEventStore.authorizationStatus(for: .event) == .authorized else {
- throw TDDataError.permissionDenied
- }
+ let status = EKEventStore.authorizationStatus(for: .event)
+ switch status {
+ case .authorized, .fullAccess:
+ break
+ default:
+ throw TDDataError.permissionDenied
+ }
// …이하 기존 로직
}• MainTabBarCoordinator.swift (권한 요청)
– iOS 17 이상에서는 requestFullAccessToEvents / requestWriteOnlyAccessToEvents API 사용
– 기존 eventStore.requestAccess(to:.event) 대신 아래처럼 분기 처리
if #available(iOS 17, *) {
eventStore.requestFullAccessToEvents { granted, error in
// granted==true → full access 승인
}
} else {
eventStore.requestAccess(to: .event) { granted, error in
// 기존 처리
}
}• Info.plist
– iOS 17 이상에서 전체 접근 요청에 필요한 NSCalendarsFullAccessUsageDescription 키 추가
– (쓰기만 필요하면 NSCalendarsUsageDescription + requestWriteOnlyAccessToEvents 사용)
위 변경 사항을 적용해 iOS 17 권한 상태(.fullAccess, .writeOnly 등)와 요청 API 모두 정상 동작하도록 확인 부탁드립니다.
| container.register(FetchServerScheduleListUseCase.self) { resolver in | ||
| guard let repository = resolver.resolve(ScheduleRepository.self) else { | ||
| fatalError("컨테이너에 ScheduleRepository가 등록되어 있지 않습니다.") | ||
| } | ||
| return FetchScheduleListUseCaseImpl(repository: repository) | ||
| return FetchServerScheduleListUseCaseImpl(repository: repository) | ||
| } | ||
|
|
||
| container.register(FetchScheduleUseCase.self) { resolver in | ||
| container.register(FetchLocalCalendarScheduleListUseCase.self) { resolver in | ||
| guard let repository = resolver.resolve(ScheduleRepository.self) else { | ||
| fatalError("컨테이너에 ScheduleRepository가 등록되어 있지 않습니다.") | ||
| } | ||
| return FetchScheduleUseCaseImpl(repository: repository) | ||
| return FetchLocalCalendarScheduleListUseCaseImpl(repository: repository) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
통합 UseCase(FetchAllSchedulesUseCase) DI 등록 누락 가능성
PR 설명에 따르면 서버/로컬을 병합하는 상위 FetchAllSchedulesUseCase가 추가되었습니다. 본 DomainAssembly에는 해당 등록이 보이지 않아, 실제 해상 시점에 resolve(FetchAllSchedulesUseCase.self)가 실패할 수 있습니다.
아래와 같이 등록을 추가해 주세요(주변 스케줄 UseCase 등록 근처 권장).
container.register(FetchAllSchedulesUseCase.self) { resolver in
guard
let serverUC = resolver.resolve(FetchServerScheduleListUseCase.self),
let localUC = resolver.resolve(FetchLocalCalendarScheduleListUseCase.self)
else {
fatalError("컨테이너에 Schedule 관련 UseCase가 등록되어 있지 않습니다.")
}
return FetchAllSchedulesUseCaseImpl(serverUseCase: serverUC, localUseCase: localUC)
}필요 시 .inObjectScope(.container) 적용 여부는 사용 패턴에 맞춰 결정해 주세요(대부분의 UseCase는 transient로 충분).
| extension ScheduleRecord { | ||
| init(recordDate: String, deletedAt: String? = nil) { | ||
| self.init( | ||
| id: Int.random(in: 1...10000), | ||
| isComplete: false, | ||
| recordDate: recordDate, | ||
| deletedAt: deletedAt | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ScheduleRecord.id 무작위 생성은 테스트 플래키니스 유발
Int.random(in:)은 실행마다 값이 달라 스냅샷/동등성 비교 등에서 테스트가 불안정해질 수 있습니다. 결정적 ID 생성으로 교체해주세요. 또한 필요 시 직접 ID를 넘길 수 있도록 파라미터를 추가하면 유연성이 높아집니다.
수정(diff):
-extension ScheduleRecord {
- init(recordDate: String, deletedAt: String? = nil) {
- self.init(
- id: Int.random(in: 1...10000),
- isComplete: false,
- recordDate: recordDate,
- deletedAt: deletedAt
- )
- }
-}
+extension ScheduleRecord {
+ init(recordDate: String, deletedAt: String? = nil, id: Int? = nil) {
+ let nextId: Int = {
+ if let id { return id }
+ defer { __testRecordIdCounter += 1 }
+ return __testRecordIdCounter
+ }()
+ self.init(
+ id: nextId,
+ isComplete: false,
+ recordDate: recordDate,
+ deletedAt: deletedAt
+ )
+ }
+}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In Projects/TDDomain/Tests/Extension/Schedule+Init.swift around lines 24 to 33,
the initializer uses Int.random(in: 1...10000) which causes test flakiness;
change the init signature to accept an optional id: Int = 1 (or another fixed
deterministic default) and use that id in the self.init call instead of
Int.random, so tests are deterministic and callers can still provide a custom id
when needed; update any tests that relied on randomness to pass an explicit id
if required.
| let todayNormalizedDate = Date().normalized | ||
| let todayFormat = todayNormalizedDate.convertToString(formatType: .yearMonthDay) | ||
| let fetchedTodaySchedules = try await fetchScheduleListUseCase.execute( | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Dictionary 키 정규화 방식 일치 필요 (Date key 불일치 가능성)
allSchedules의 Date 키는 서버/로컬 유스케이스에서 Calendar.current.startOfDay로 묶이는 반면, 여기서는 Date().normalized를 사용합니다. 구현이 동일하다는 보장이 없으면 키 미스매치로 오늘 데이터가 비어 보일 수 있습니다. startOfDay를 명시적으로 사용해 키를 맞추는 편이 안전합니다.
적용 예시:
- let todayNormalizedDate = Date().normalized
- let todayFormat = todayNormalizedDate.convertToString(formatType: .yearMonthDay)
+ let todayKey = Calendar.current.startOfDay(for: Date())
+ let todayFormat = todayKey.convertToString(formatType: .yearMonthDay)
…
- if let todaySchedules = allSchedules[todayNormalizedDate], !todaySchedules.isEmpty {
+ if let todaySchedules = allSchedules[todayKey], !todaySchedules.isEmpty {Also applies to: 72-72
🤖 Prompt for AI Agents
In
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift
around lines 64-66 (and also line 72), the code uses Date().normalized to create
the "today" key which may not match other places that use
Calendar.current.startOfDay(for:), causing dictionary key mismatches; replace
Date().normalized with an explicit Calendar.current.startOfDay(for: Date()) when
normalizing the today date (and any other similar date normalization at line 72)
so the key generation matches the server/local usage.
| currentSchedules = [] | ||
| uncompletedSchedules = [] | ||
| output.send(.fetchedScheduleList(isEmpty: true)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
빈 결과 시 isAllDays 리셋 누락
오늘 일정이 없을 때 current/uncompleted는 비우지만 isAllDays는 이전 값이 남을 수 있습니다. UI 오표시 방지를 위해 false로 리셋하는 것이 안전합니다.
적용 예시:
} else {
- currentSchedules = []
- uncompletedSchedules = []
+ currentSchedules = []
+ uncompletedSchedules = []
+ isAllDays = false
output.send(.fetchedScheduleList(isEmpty: true))
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| currentSchedules = [] | |
| uncompletedSchedules = [] | |
| output.send(.fetchedScheduleList(isEmpty: true)) | |
| } else { | |
| currentSchedules = [] | |
| uncompletedSchedules = [] | |
| isAllDays = false | |
| output.send(.fetchedScheduleList(isEmpty: true)) | |
| } |
🤖 Prompt for AI Agents
In
Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift
around lines 96–98, when no schedules are found the code clears currentSchedules
and uncompletedSchedules but fails to reset isAllDays; update this branch to
explicitly set isAllDays = false (before or after clearing the arrays) so the UI
cannot display a stale “all days” state, and ensure any related downstream
output/event reflects the reset state.
| private func configurePushNotification() { | ||
| UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in | ||
| if granted { | ||
| DispatchQueue.main.async { | ||
| UIApplication.shared.registerForRemoteNotifications() | ||
| } | ||
|
|
||
| if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil { | ||
| UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey) | ||
| } | ||
| } else { | ||
| TDLogger.info("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")") | ||
| UserDefaults.standard.set(false, forKey: "PushEnabled") | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
UserDefaults 키 불일치 및 상태 동기화 누락 (실제 버그)
- Line 61: 거부/오류 경로에
"PushEnabled"리터럴 키를 사용하고, 승인 경로는UserDefaultsConstant.pushEnabledKey를 사용합니다. 키가 불일치하여 앱 내 토글/표시가 쉽게 불일치 상태가 됩니다. - 최초 1회만 true를 기록(라인 56~58)하고 이후 변경은 반영하지 않습니다. 사용자가 설정에서 권한을 바꿔도 저장된 상태가 갱신되지 않습니다.
- 승인될 때만 원격 등록을 시도합니다. 일반적으로 앱 런치 시 승인되어 있으면 매번
registerForRemoteNotifications()를 호출해도 안전하며 토큰 재발급 시나리오 대응에 유리합니다.
아래처럼 키를 일관되게 사용하고, 항상 현재 권한값으로 상태를 동기화하며, 메인 스레드에서 원격 등록까지 처리하는 단순한 형태로 정리하는 것을 제안합니다.
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
- if granted {
- DispatchQueue.main.async {
- UIApplication.shared.registerForRemoteNotifications()
- }
-
- if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil {
- UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey)
- }
- } else {
- TDLogger.info("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")")
- UserDefaults.standard.set(false, forKey: "PushEnabled")
- }
- }
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
+ DispatchQueue.main.async {
+ if let error {
+ TDLogger.error("🚨 푸시 권한 요청 중 오류: \(error.localizedDescription)")
+ }
+ UserDefaults.standard.set(granted, forKey: UserDefaultsConstant.pushEnabledKey)
+ if granted {
+ UIApplication.shared.registerForRemoteNotifications()
+ TDLogger.info("✅ 푸시 알림 권한 승인됨")
+ } else {
+ TDLogger.info("❌ 푸시 알림 권한 거부됨")
+ }
+ }
+ }추가로, TDCore에 이미 LocalPushNotificationManager.requestAuthorization가 있으므로(프로젝트 전역 일관성/테스트 용이성 측면), 아래처럼 해당 매니저를 활용하는 리팩터도 고려해주세요:
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
- ...
- }
+ let manager = LocalPushNotificationManager()
+ manager.requestAuthorization { granted in
+ UserDefaults.standard.set(granted, forKey: UserDefaultsConstant.pushEnabledKey)
+ if granted {
+ UIApplication.shared.registerForRemoteNotifications()
+ TDLogger.info("✅ 푸시 알림 권한 승인됨")
+ } else {
+ TDLogger.info("❌ 푸시 알림 권한 거부됨")
+ }
+ }권한 키/중복 구현 여부를 빠르게 점검할 수 있는 스크립트입니다:
🏁 Script executed:
#!/bin/bash
# 1) 불일치 키/직접 문자열 사용 여부 점검
rg -n -C2 'UserDefaultsConstant\.pushEnabledKey|["'\'']PushEnabled["'\'']'
# 2) 푸시 권한 요청 구현이 여러 곳에 중복되어 있는지 점검
rg -n -C3 'UNUserNotificationCenter\.current\(\)\.requestAuthorization'
# 3) Info.plist에 캘린더 권한 문구가 있는지 점검
rg -n 'NSCalendarsUsageDescription' -SLength of output: 7160
MainTabBarCoordinator.swift: UserDefaults 키 일관성 및 권한 상태 동기화 필수 수정
아래 사항을 반영해 꼭 수정 부탁드립니다.
- UserDefaults 키 일관성
• 현재 거부 경로에서 리터럴"PushEnabled"를 사용하고 있으나, 상수UserDefaultsConstant.pushEnabledKey("PushEnabled")와 매칭되므로 기능상 문제는 없지만, 코드 스타일 차원에서 반드시 상수를 사용하도록 통일해야 합니다. - 권한 상태 동기화 로직 버그
• 승인 시 최초 한 번만 기록하도록 한if … == nil조건 때문에,
사용자가 한 번 거부했다가 이후 승인해도 상태가false로 남아 업데이트되지 않습니다.
• 승인·거부 여부에 관계없이 항상 최신granted값을 UserDefaults에 저장하도록 수정해야 합니다. - 원격 알림 등록(re-register)
• 권한이 이미 승인된 상태라면, 앱 런치 시마다UIApplication.shared.registerForRemoteNotifications()를 호출해도 무방하며, 토큰 갱신 대응을 위해 매번 호출하는 것을 권장합니다. - (선택) LocalPushNotificationManager 활용
• TDCore의LocalPushNotificationManager.requestAuthorization을 사용해 중복 구현을 제거하고 전역 일관성 및 테스트 용이성을 높일 수 있습니다.
수정 예시(diff):
private func configurePushNotification() {
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
- if granted {
- DispatchQueue.main.async {
- UIApplication.shared.registerForRemoteNotifications()
- }
-
- if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil {
- UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey)
- }
- } else {
- TDLogger.info("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")")
- UserDefaults.standard.set(false, forKey: "PushEnabled")
- }
- }
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
+ DispatchQueue.main.async {
+ if let error = error {
+ TDLogger.error("🚨 푸시 권한 요청 오류: \(error.localizedDescription)")
+ }
+ // 승인 여부를 항상 동기화
+ UserDefaults.standard.set(granted, forKey: UserDefaultsConstant.pushEnabledKey)
+
+ if granted {
+ // 매번 re-register 호출
+ UIApplication.shared.registerForRemoteNotifications()
+ TDLogger.info("✅ 푸시 알림 권한 승인됨")
+ } else {
+ TDLogger.info("❌ 푸시 알림 권한 거부됨")
+ }
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private func configurePushNotification() { | |
| UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in | |
| if granted { | |
| DispatchQueue.main.async { | |
| UIApplication.shared.registerForRemoteNotifications() | |
| } | |
| if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil { | |
| UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey) | |
| } | |
| } else { | |
| TDLogger.info("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")") | |
| UserDefaults.standard.set(false, forKey: "PushEnabled") | |
| } | |
| } | |
| } | |
| private func configurePushNotification() { | |
| UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in | |
| DispatchQueue.main.async { | |
| if let error = error { | |
| TDLogger.error("🚨 푸시 권한 요청 오류: \(error.localizedDescription)") | |
| } | |
| // 승인 여부를 항상 동기화 | |
| UserDefaults.standard.set(granted, forKey: UserDefaultsConstant.pushEnabledKey) | |
| if granted { | |
| // 매번 re-register 호출 | |
| UIApplication.shared.registerForRemoteNotifications() | |
| TDLogger.info("✅ 푸시 알림 권한 승인됨") | |
| } else { | |
| TDLogger.info("❌ 푸시 알림 권한 거부됨") | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift
around lines 49-64, replace the current UserDefaults literal and the
conditional-only-once write with a consistent use of
UserDefaultsConstant.pushEnabledKey and always persist the latest granted
boolean (UserDefaults.standard.set(granted, forKey:
UserDefaultsConstant.pushEnabledKey)); call
UIApplication.shared.registerForRemoteNotifications() whenever granted is true
(so token re-registration happens on each launch), and optionally refactor to
use TDCore's LocalPushNotificationManager.requestAuthorization to remove
duplicate authorization logic.
| private func checkCalendarPermissions() { | ||
| let eventStore = EKEventStore() | ||
| let status = EKEventStore.authorizationStatus(for: .event) | ||
|
|
||
| switch status { | ||
| case .authorized: | ||
| TDLogger.info("✅ 캘린더 접근 권한이 이미 승인되었습니다.") | ||
| case .notDetermined: | ||
| eventStore.requestAccess(to: .event) { (granted, error) in | ||
| if let error = error { | ||
| TDLogger.error("🚨 캘린더 권한 요청 중 오류 발생: \(error.localizedDescription)") | ||
| return | ||
| } | ||
|
|
||
| DispatchQueue.main.async { | ||
| if granted { | ||
| TDLogger.info("✅ 캘린더 접근 권한이 승인되었습니다.") | ||
| } else { | ||
| TDLogger.info("❌ 캘린더 접근 권한이 거부되었습니다.") | ||
| } | ||
| } | ||
| } | ||
| case .denied, .restricted: | ||
| TDLogger.info("❌ 캘린더 접근 권한이 거부되었거나 제한되었습니다.") | ||
| default: | ||
| TDLogger.info("Unhandled EKAuthorizationStatus case") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
iOS 17+ EventKit 권한 API 반영(.fullAccess/.writeOnly 구분) 및 deprecated 교체 필요
iOS 17부터 EKEventStore.requestAccess(to: .event)가 deprecated이고, 권한 상태에 .fullAccess, .writeOnly가 추가되었습니다. 현재 구현은 .authorized만 성공으로 간주하고 나머지를 default로 처리하여 iOS 17 환경에서 읽기 불가(.writeOnly)를 놓칠 수 있습니다. 로컬 캘린더 이벤트 “읽기”가 핵심인 기능 특성상, iOS 17+에서는 반드시 “전체 접근(full access)”을 요청/확인해야 합니다.
아래처럼 플랫폼 분기 및 신규 상태를 명시적으로 처리하고, 요청 API도 requestFullAccessToEvents로 교체하는 것을 권장합니다. 또한 unknown 케이스는 @unknown default로 처리해 새 케이스 추가 시 컴파일러 경고를 받을 수 있도록 합니다.
private func checkCalendarPermissions() {
let eventStore = EKEventStore()
- let status = EKEventStore.authorizationStatus(for: .event)
-
- switch status {
- case .authorized:
- TDLogger.info("✅ 캘린더 접근 권한이 이미 승인되었습니다.")
- case .notDetermined:
- eventStore.requestAccess(to: .event) { (granted, error) in
- if let error = error {
- TDLogger.error("🚨 캘린더 권한 요청 중 오류 발생: \(error.localizedDescription)")
- return
- }
-
- DispatchQueue.main.async {
- if granted {
- TDLogger.info("✅ 캘린더 접근 권한이 승인되었습니다.")
- } else {
- TDLogger.info("❌ 캘린더 접근 권한이 거부되었습니다.")
- }
- }
- }
- case .denied, .restricted:
- TDLogger.info("❌ 캘린더 접근 권한이 거부되었거나 제한되었습니다.")
- default:
- TDLogger.info("Unhandled EKAuthorizationStatus case")
- }
+ if #available(iOS 17.0, *) {
+ switch EKEventStore.authorizationStatus(for: .event) {
+ case .fullAccess:
+ TDLogger.info("✅ 캘린더 전체 접근 권한이 이미 승인되었습니다.")
+ case .writeOnly:
+ TDLogger.info("⚠️ 캘린더 쓰기 전용 권한만 승인됨(읽기 불가). 전체 접근 권한이 필요합니다.")
+ case .notDetermined:
+ eventStore.requestFullAccessToEvents { granted, error in
+ DispatchQueue.main.async {
+ if let error {
+ TDLogger.error("🚨 캘린더 권한 요청 중 오류: \(error.localizedDescription)")
+ } else if granted {
+ TDLogger.info("✅ 캘린더 전체 접근 권한이 승인되었습니다.")
+ } else {
+ TDLogger.info("❌ 캘린더 전체 접근 권한이 거부되었습니다.")
+ }
+ }
+ }
+ case .denied, .restricted:
+ TDLogger.info("❌ 캘린더 접근 권한이 거부되었거나 제한되었습니다.")
+ @unknown default:
+ TDLogger.info("🟡 미처리 EKAuthorizationStatus case")
+ }
+ } else {
+ switch EKEventStore.authorizationStatus(for: .event) {
+ case .authorized:
+ TDLogger.info("✅ 캘린더 접근 권한이 이미 승인되었습니다.")
+ case .notDetermined:
+ eventStore.requestAccess(to: .event) { granted, error in
+ DispatchQueue.main.async {
+ if let error {
+ TDLogger.error("🚨 캘린더 권한 요청 중 오류: \(error.localizedDescription)")
+ } else if granted {
+ TDLogger.info("✅ 캘린더 접근 권한이 승인되었습니다.")
+ } else {
+ TDLogger.info("❌ 캘린더 접근 권한이 거부되었습니다.")
+ }
+ }
+ }
+ case .denied, .restricted:
+ TDLogger.info("❌ 캘린더 접근 권한이 거부되었거나 제한되었습니다.")
+ @unknown default:
+ TDLogger.info("🟡 미처리 EKAuthorizationStatus case")
+ }
+ }
}선택 사항: 거부 상태에서 “설정으로 이동” 유도 알럿을 띄우면 회복 UX가 좋아집니다(Coordinator에서 프레젠트 가능).
let url = URL(string: UIApplication.openSettingsURLString)!
UIApplication.shared.open(url)🤖 Prompt for AI Agents
In Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift
around lines 66 to 93, update the calendar-permissions flow to support iOS 17+
EventKit changes: replace the deprecated requestAccess(to: .event) call with the
new requestFullAccessToEvents API when available, explicitly handle new
authorization statuses (.fullAccess and .writeOnly) so only .fullAccess is
treated as success for read operations, treat .writeOnly as insufficient, and
handle .denied/.restricted by prompting the user (optionally with an alert to
open Settings). Also change the switch default to @unknown default to surface
future cases and ensure all async callbacks dispatch back to the main queue for
UI/logging.
| <key>NSCalendarsUsageDescription</key> | ||
| <string>토덕에서 생성한 일정을 캘린더 앱에 자동으로 등록하고 관리하기 위해 사용자의 iOS 캘린더에 접근합니다.</string> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
권한 안내 문구가 현재 기능과 불일치합니다(읽기 전용인데 ‘자동 등록·관리’ 명시)
PR 설명에 따르면 이번 릴리스에서는 “로컬 이벤트는 앱 내에서 읽기 전용”입니다. 그런데 안내 문구는 “자동으로 등록하고 관리”라고 되어 있어, 심사/사용자 신뢰 관점에서 리스크입니다. 현재 기능에 맞춰 “조회/표시” 중심으로 수정해 주세요.
수정 제안(한국어):
-토덕에서 생성한 일정을 캘린더 앱에 자동으로 등록하고 관리하기 위해 사용자의 iOS 캘린더에 접근합니다.
+사용자의 iOS 캘린더 일정을 앱에서 조회하여 서버 일정과 함께 보여주기 위해 접근 권한이 필요합니다.🤖 Prompt for AI Agents
In Projects/toduck/SupportingFiles/Info.plist around lines 62-63, the
NSCalendarsUsageDescription string incorrectly states the app will "자동으로 등록하고
관리" while the feature is read-only; update the string to reflect read-only
access (e.g., mention 조회/표시 또는 읽기 전용) so it matches current behavior and app
review expectations, making sure the new Korean text clearly says the app only
accesses the iOS calendar to read/fetch and display events created in Toduck.
| private func mapToSchedules(from events: [EKEvent]) -> [Schedule] { | ||
| return events.map { event in | ||
| return Schedule( | ||
| id: event.eventIdentifier.hashValue, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기 hashValue 값이랑 서버에서 저장하고있는 id값이랑 겹치는 일은 없겠죠 ?
#️⃣ 연관된 이슈
📝 작업 내용
FetchAllSchedulesUseCase) 추가📒 리뷰 노트
📸 스크린샷
Summary by CodeRabbit