Skip to content

Conversation

@Kyxxn
Copy link
Member

@Kyxxn Kyxxn commented Aug 22, 2025

#️⃣ 연관된 이슈


📝 작업 내용

  • iOS 캘린더(EventKit) 데이터 접근을 위한 Storage/Repository 계층 구현
  • 로컬 이벤트를 요일별로 정리하는 UseCase 구현 및 테스트 코드 작성
  • 서버 일정과 로컬 일정을 통합 조회하는 최상위 UseCase(FetchAllSchedulesUseCase) 추가
  • ViewModel에서 통합된 일정 목록을 받아 UI에 표시하도록 적용

📒 리뷰 노트

  • 사용자가 아이폰 기본 캘린더에 등록한 일정을 앱 내에서 서버 일정과 함께 확인할 수 있도록 개선했습니다.
    • 현재는 아이폰에 있는 일정을 토덕에 보여주는 용도로만 설정되어 있습니다.
    • 토덕에서 일정 수정/삭제 시 로컬에 반영되는 것은 추후 예정입니다.
  • 기존에는 서버 일정만 표시되었지만, EventKit 연동을 통해 로컬 일정도 함께 가져와 일관성 있게 통합 관리할 수 있습니다.
  • 일정 데이터 소스 통합 로직을 ViewModel이 아닌 UseCase 계층에서 처리하여, ViewModel의 책임을 단순화하고 아키텍처 일관성을 유지했습니다.

📸 스크린샷

로컬 일정을 불러와 서버와 함께 합쳐진 UI
로컬 일정을 불러와 서버와 함께 합쳐진 UI

Summary by CodeRabbit

  • New Features
    • iOS 로컬 캘린더 연동: 서버 일정과 로컬 일정이 함께 표시됩니다.
    • 홈/투덕 뷰에서 오늘·주간 일정 집계 시 로컬 캘린더 일정도 포함됩니다.
  • Permissions
    • 캘린더 접근 권한 요청을 추가하고, 권한 상태에 따른 처리 및 안내를 강화했습니다.
    • 푸시 알림 권한 요청 흐름을 정비했습니다.
  • Chores
    • 캘린더 접근 목적 설명(NSCalendarsUsageDescription)을 추가했습니다.
    • 권한 거부 상황에 대한 에러 문구가 보완되었습니다.

@Kyxxn Kyxxn requested a review from Sonny-Kor August 22, 2025 06:32
@Kyxxn Kyxxn self-assigned this Aug 22, 2025
@Kyxxn Kyxxn added the ✨ feature 새로운 기능 구현 label Aug 22, 2025
@Kyxxn Kyxxn linked an issue Aug 22, 2025 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Aug 22, 2025

Walkthrough

서버 일정과 로컬 캘린더 일정을 분리·통합하는 구조로 리팩터링했습니다. Schedule에 source를 추가하고 관련 이니셜라이저·매핑을 갱신했습니다. 저장소 계층에 ScheduleStorage를 도입해 EventKit으로 캘린더 이벤트를 조회합니다. 도메인 유스케이스/어셈블리/프레젠테이션을 이에 맞게 교체하고, 권한 안내(Info.plist)와 권한 체크 로직을 추가했습니다.

Changes

Cohort / File(s) Summary
Core Error 확장
Projects/TDCore/Sources/Error/TDDataError.swift
TDDataError에 permissionDenied 케이스 추가 및 description 처리 업데이트.
Domain 엔터티/프로토콜 변경
Projects/TDDomain/Sources/Entity/Schedule.swift, Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift
Schedule.Source(server/localCalendar) 추가, Schedule에 source 프로퍼티와 이니셜라이저 파라미터 추가. 저장소 프로토콜을 서버/로컬 분리 메서드로 교체, 단일 조회/내일로 이동 API 제거.
Domain 유스케이스 개편
.../UseCase/Schedule/FetchServerScheduleListUseCase.swift, .../UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift, .../UseCase/Schedule/FetchAllSchedulesUseCase.swift, .../UseCase/Schedule/FetchScheduleUseCase.swift, .../UseCase/Schedule/MoveTomorrowScheduleUseCase.swift, .../Utility/Date+weekdayEnum.swift, .../DomainAssembly.swift
서버/로컬 분리 유스케이스 추가 및 기존 명칭/호출 변경, 모든 일정 병합 FetchAllSchedulesUseCase 신설, 단일 조회/내일 이동 유스케이스 제거, 요일 유틸 추가, 의존성 등록 갱신.
Data 계층: DTO/Repository/Assembly
Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift, Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift, Projects/TDData/Sources/DataAssembly.swift
서버 매핑 시 Schedule.source = .server 적용. ScheduleRepositoryImpl에 ScheduleStorage 의존성 추가, 서버/로컬 분리 fetch 메서드로 API 변경, 로컬 캘린더 조회 및 매핑(EKEvent→Schedule.source = .localCalendar) 구현. DataAssembly에서 storage 주입.
Storage 계층 도입
Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift, Projects/TDStorage/Sources/ScheduleStorageImpl.swift, Projects/TDStorage/Sources/StorageAssembly.swift
ScheduleStorage 프로토콜 추가. EventKit 기반 ScheduleStorageImpl 구현(권한 미승인 시 TDDataError.permissionDenied). StorageAssembly에 등록.
Presentation: 캘린더/홈 흐름 갱신
Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift, .../Calendar/ToduckCalendarViewModel.swift, .../Home/HomeViewController.swift, .../Home/Todo/TodoViewModel.swift, .../Home/Toduck/ToduckViewModel.swift, .../Home/EventMakor/TodoCreatorViewModel.swift
캘린더 화면은 FetchServerScheduleListUseCase로 전환. 홈 화면은 FetchAllSchedulesUseCase로 통합 조회. ViewModel 이니셜라이저/호출 시그니처 변경 및 Schedule 생성 시 source 전달. ToduckViewModel 로직은 source에 따른 미완료 분류 규칙 반영.
Permissions 및 권한 처리
Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift, Projects/toduck/SupportingFiles/Info.plist
푸시 및 캘린더 권한 요청 로직 추가(EKEventStore 사용), 로그 처리 정리. Info.plist에 NSCalendarsUsageDescription 추가.
테스트 업데이트
Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift, .../FetchLocalCalendarScheduleListUseCaseTests.swift, .../Extension/Schedule+Init.swift, .../Mock/MockScheduleRepository.swift, .../ShouldMarkAllDayUseCaseTests.swift, .../FetchScheduleListUseCaseTests.swift
서버/로컬 분리 및 source 추가에 맞춘 테스트 신설/수정, 편의 이니셜라이저 추가, 기존 리스트 유스케이스 테스트 삭제, 목 저장소 인터페이스 조정 및 에러 주입 추가.

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: 화면 갱신
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • N-Joy-Shadow
  • Sonny-Kor

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/toduck-schedule-in-iphone-calendar

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary or @coderabbitai 요약 to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a 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 산출 로직이 사용자 입력을 덮어쓸 수 있음

현재 isAllDaytime == 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.resolve vs DIContainer.shared.resolve) 및 수동 인스턴스화(FetchAllSchedulesUseCaseImpl(...))를 한 가지 방식으로 통일해 테스트 편의성과 가독성을 높여주세요.

점검 위치

  • Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift
  • Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift
  • Projects/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: 버그: 딕셔너리 키 접근 시 selectedDateselectedDate.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: 로컬 캘린더 일정은 읽기 전용: 체크박스 처리에서 서버 일정만 완료 처리하도록 가드 필요

현재 finishTodofinishSchedule 경로에서 로컬 캘린더 일정도 .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로 설정.

  • 서버가 레코드를 빈 배열로 보낼 때, scheduleRecordsnil로 두고 싶다면 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: 타임존/로케일 고정으로 테스트 결정론 강화

문자열 날짜 파싱 시 환경별 타임존/로케일에 따라 오차가 날 수 있습니다. DateFormatterCalendar에 동일한 타임존을 명시하면 테스트가 견고해집니다.

-        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/FetchAllSchedulesUseCaseImplSendable을 부여하면 안전성이 향상됩니다(내부 의존성이 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):

  1. 메서드 단위 적용
- private func fetchScheduleList() async {
+ @MainActor
+ private func fetchScheduleList() async {
      do {
          …
      } catch {
          …
      }
  }
  1. 클래스 전체 적용
- 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 01d2664 and f22ceda.

📒 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.Sourcesource 속성 도입: 출처 추적에 유용 — 도메인 확장성 측면에서 적절

서버/로컬 캘린더 출처를 도메인 모델에 포함한 선택이 훌륭합니다. 이후 정책 분기(UI/권한/동기화)에서 명확한 기준이 됩니다.

Also applies to: 23-24, 42-59

Projects/TDCore/Sources/Error/TDDataError.swift (1)

17-17: 권한 분기 및 재사용 현황 확인 완료

  • TDDataError.permissionDeniedScheduleStorageImpl.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 이니셜라이저 변경에 따라 scheduleRecordssource: .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.swiftinit(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 등록 적절합니다

FetchServerScheduleListUseCaseFetchServerScheduleListUseCaseImpl 바인딩이 명확하며, 기존 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> 사용은 중복 제거에 유효하나, ScheduleHashable 동등성 정의가 ID 기준인지 필드 전체 기준인지에 따라 의도치 않은 제거가 일어날 수 있습니다.

필요 시 Schedulehash(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: 시간 정렬 로직 LGTM

Date.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를 직접 호출하는 구문은 존재하지 않습니다

따라서 메서드 네이밍 변경으로 인한 호출부 불일치는 없으며, 추가 작업이 필요 없습니다.

Comment on lines +48 to +66
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
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

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
}

추가적으로(선택):

  • 카테고리 색상: EKCalendarcgColor를 노출합니다. 변환 유틸이 있다면 해당 색상으로 매핑해 주면 UX 일관성이 좋아집니다. 없으면 현행 기본값 유지도 OK입니다.
  • all-day/다일 이벤트: 현재는 단일 startDate/endDate만 설정합니다. 다일 확장(여러 날짜에 분할 표시)은 UseCase/UI에서 처리 중인지 확인해 주세요.

필요하시면 위 변경에 대한 유닛 테스트(같은 eventIdentifier → 항상 같은 ID, 프로세스 간 일관성, 음수 보장)를 작성해 드릴 수 있습니다.

Comment on lines +1 to +3
import Foundation
import EventKit

Copy link

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 모두 정상 동작하도록 확인 부탁드립니다.

Comment on lines +316 to 328
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

통합 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로 충분).

Comment on lines +24 to +33
extension ScheduleRecord {
init(recordDate: String, deletedAt: String? = nil) {
self.init(
id: Int.random(in: 1...10000),
isComplete: false,
recordDate: recordDate,
deletedAt: deletedAt
)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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.

Comment on lines 64 to +66
let todayNormalizedDate = Date().normalized
let todayFormat = todayNormalizedDate.convertToString(formatType: .yearMonthDay)
let fetchedTodaySchedules = try await fetchScheduleListUseCase.execute(

Copy link

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.

Comment on lines +96 to 98
currentSchedules = []
uncompletedSchedules = []
output.send(.fetchedScheduleList(isEmpty: true))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

빈 결과 시 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.

Suggested change
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.

Comment on lines +49 to +64
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")
}
}
}
Copy link

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

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

Suggested change
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.

Comment on lines +66 to 93
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")
}
}
Copy link

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.

Comment on lines +62 to +63
<key>NSCalendarsUsageDescription</key>
<string>토덕에서 생성한 일정을 캘린더 앱에 자동으로 등록하고 관리하기 위해 사용자의 iOS 캘린더에 접근합니다.</string>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

권한 안내 문구가 현재 기능과 불일치합니다(읽기 전용인데 ‘자동 등록·관리’ 명시)

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 hashValue 값이랑 서버에서 저장하고있는 id값이랑 겹치는 일은 없겠죠 ?

@Kyxxn Kyxxn changed the title 아이폰 캘린더 연동 및 토덕 서버의 일정과 통합하여 함께 표시 아이폰 캘린더 연동 & 토덕 서버의 일정과 통합하여 함께 표시 Sep 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature 새로운 기능 구현

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

토덕의 '일정'과 아이폰 캘린더 연동

3 participants