From af2067492b954f866b9fad9ac0303c29c03258cb Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 15:17:22 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20Todo=EC=9D=98=20=EA=B0=81?= =?UTF-8?q?=EC=A2=85=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=AA=A8=EC=95=84=20?= =?UTF-8?q?=ED=95=9C=EB=88=88=EC=97=90=20=EB=B3=B4=EC=97=AC=EC=A3=BC?= =?UTF-8?q?=EB=8A=94=20=EB=B7=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/TodayTodoItem.swift | 28 ++ .../ViewModel/TodayViewModel.swift | 259 ++++++++++++++++++ DevLog/Resource/Localizable.xcstrings | 9 + DevLog/UI/Common/MainView.swift | 9 + DevLog/UI/Today/TodayView.swift | 246 +++++++++++++++++ 5 files changed, 551 insertions(+) create mode 100644 DevLog/Presentation/Structure/TodayTodoItem.swift create mode 100644 DevLog/Presentation/ViewModel/TodayViewModel.swift create mode 100644 DevLog/UI/Today/TodayView.swift diff --git a/DevLog/Presentation/Structure/TodayTodoItem.swift b/DevLog/Presentation/Structure/TodayTodoItem.swift new file mode 100644 index 00000000..525e4f54 --- /dev/null +++ b/DevLog/Presentation/Structure/TodayTodoItem.swift @@ -0,0 +1,28 @@ +// +// TodayTodoItem.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +struct TodayTodoItem: Identifiable, Hashable { + let id: String + let title: String + let tags: [String] + let isPinned: Bool + let updatedAt: Date + let dueDate: Date? + let kind: TodoKind + + init(from todo: Todo) { + self.id = todo.id + self.title = todo.title + self.tags = todo.tags + self.isPinned = todo.isPinned + self.updatedAt = todo.updatedAt + self.dueDate = todo.dueDate + self.kind = todo.kind + } +} diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift new file mode 100644 index 00000000..7aa21d6e --- /dev/null +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -0,0 +1,259 @@ +// +// TodayViewModel.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +@Observable +final class TodayViewModel: Store { + typealias SectionContent = (title: String, items: [TodayTodoItem]) + + struct State: Equatable { + var todos: [TodayTodoItem] = [] + var isLoading: Bool = false + var showAlert: Bool = false + var alertTitle: String = "" + var alertMessage: String = "" + } + + enum Action { + case refresh + case setAlert(Bool) + case completeTodo(TodayTodoItem) + case togglePinned(TodayTodoItem) + case onAppear + case fetchTodos([TodayTodoItem]) + case setLoading(Bool) + case updateTodo(TodayTodoItem) + case removeTodo(String) + } + + enum SideEffect { + case fetchTodos + case completeTodo(TodayTodoItem) + case togglePinned(TodayTodoItem) + } + + private(set) var state = State() + private let calendar = Calendar.current + private let pageSize = 20 + private let upcomingWindowDays = 7 + private let fetchTodosUseCase: FetchTodosUseCase + private let fetchTodoByIDUseCase: FetchTodoByIDUseCase + private let upsertTodoUseCase: UpsertTodoUseCase + + init( + fetchTodosUseCase: FetchTodosUseCase, + fetchTodoByIDUseCase: FetchTodoByIDUseCase, + upsertTodoUseCase: UpsertTodoUseCase + ) { + self.fetchTodosUseCase = fetchTodosUseCase + self.fetchTodoByIDUseCase = fetchTodoByIDUseCase + self.upsertTodoUseCase = upsertTodoUseCase + } + + var remainingCount: Int { state.todos.count } + + var focusedCount: Int { + state.todos.filter(\.isPinned).count + } + + var overdueCount: Int { + state.todos.filter(isOverdue).count + } + + var dueSoonCount: Int { + state.todos.filter(isDueSoon).count + } + + var sections: [SectionContent] {[ + ("집중할 일", sortedScheduledItems(state.todos.filter(\.isPinned))), + ("지난 마감", sortedScheduledItems(state.todos.filter { !$0.isPinned && isOverdue($0) })), + ("\(upcomingWindowDays)일 내 일정", sortedScheduledItems(state.todos.filter { !$0.isPinned && isDueSoon($0) })), + ("나중 일정", sortedScheduledItems(state.todos.filter { !$0.isPinned && isScheduledLater($0) })), + ("일정 미정", unscheduledItems) + ] + .filter { !$0.items.isEmpty } + } + + func reduce(with action: Action) -> [SideEffect] { + var state = self.state + var effects: [SideEffect] = [] + + switch action { + case .refresh, .setAlert, .completeTodo, .togglePinned: + effects = reduceByUser(action, state: &state) + case .onAppear: + effects = reduceByView(action, state: &state) + case .fetchTodos, .setLoading, .updateTodo, .removeTodo: + effects = reduceByRun(action, state: &state) + } + + if self.state != state { self.state = state } + return effects + } + + func run(_ effect: SideEffect) { + switch effect { + case .fetchTodos: + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let page = try await fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + send(.fetchTodos(page.items.map { TodayTodoItem(from: $0) })) + } catch { + send(.setAlert(true)) + } + } + case .completeTodo(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + var todo = try await fetchTodoByIDUseCase.execute(item.id) + let now = Date() + todo.isCompleted = true + todo.completedAt = now + todo.updatedAt = now + try await upsertTodoUseCase.execute(todo) + send(.removeTodo(todo.id)) + } catch { + send(.setAlert(true)) + } + } + case .togglePinned(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + var todo = try await fetchTodoByIDUseCase.execute(item.id) + todo.isPinned.toggle() + todo.updatedAt = Date() + try await upsertTodoUseCase.execute(todo) + send(.updateTodo(TodayTodoItem(from: todo))) + } catch { + send(.setAlert(true)) + } + } + } + } +} + +private extension TodayViewModel { + func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .refresh: + return [.fetchTodos] + case .setAlert(let isPresented): + setAlert(&state, isPresented: isPresented) + case .completeTodo(let item): + return [.completeTodo(item)] + case .togglePinned(let item): + return [.togglePinned(item)] + default: + break + } + return [] + } + + func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .onAppear: + return [.fetchTodos] + default: + break + } + return [] + } + + func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { + switch action { + case .fetchTodos(let items): + state.todos = items + case .setLoading(let isLoading): + state.isLoading = isLoading + case .updateTodo(let item): + if let index = state.todos.firstIndex(where: { $0.id == item.id }) { + state.todos[index] = item + } else { + state.todos.append(item) + } + case .removeTodo(let todoID): + state.todos.removeAll { $0.id == todoID } + default: + break + } + return [] + } + + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } + + func isOverdue(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) + } + + func isDueSoon(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { + return false + } + let dueDay = calendar.startOfDay(for: dueDate) + return startOfToday <= dueDay && dueDay <= windowEnd + } + + func isScheduledLater(_ item: TodayTodoItem) -> Bool { + guard let dueDate = item.dueDate else { return false } + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { + return false + } + let dueDay = calendar.startOfDay(for: dueDate) + return windowEnd < dueDay + } + + func sortedScheduledItems(_ items: [TodayTodoItem]) -> [TodayTodoItem] { + items.sorted { lhs, rhs in + switch (lhs.dueDate, rhs.dueDate) { + case let (left?, right?): + let leftDay = calendar.startOfDay(for: left) + let rightDay = calendar.startOfDay(for: right) + if leftDay != rightDay { return leftDay < rightDay } + return lhs.updatedAt > rhs.updatedAt + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + return lhs.updatedAt > rhs.updatedAt + } + } + } + + var unscheduledItems: [TodayTodoItem] { + state.todos + .filter { !$0.isPinned && $0.dueDate == nil } + .sorted { $0.updatedAt > $1.updatedAt } + } +} diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 89a502fe..525b46de 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -263,6 +263,9 @@ }, "기간" : { + }, + "남아 있는 Todo가 없습니다." : { + }, "더보기" : { @@ -335,12 +338,18 @@ }, "연동된 계정" : { + }, + "오늘" : { + }, "완료" : { }, "완료 상태" : { + }, + "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." : { + }, "완료일" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 69e4cef6..e5e4789a 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -23,6 +23,15 @@ struct MainView: View { Image(systemName: "house.fill") Text("홈") } + TodayView(viewModel: TodayViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIDUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self) + )) + .tabItem { + Image(systemName: "sun.max.fill") + Text("오늘") + } PushNotificationListView(viewModel: PushNotificationListViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift new file mode 100644 index 00000000..08a04653 --- /dev/null +++ b/DevLog/UI/Today/TodayView.swift @@ -0,0 +1,246 @@ +// +// TodayView.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import SwiftUI + +struct TodayView: View { + @Environment(\.diContainer) private var container: any DIContainer + @State private var router = NavigationRouter() + @State var viewModel: TodayViewModel + + var body: some View { + let sections = viewModel.sections + + NavigationStack(path: $router.path) { + List { + summarySection + if sections.isEmpty, !viewModel.state.isLoading { + emptySection + } else { + ForEach(Array(sections.indices), id: \.self) { index in + let section = sections[index] + todoSection(section.title, items: section.items) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("오늘") + .navigationDestination(for: Path.self) { path in + switch path { + case .detail(let todoID): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoID: todoID + )) + } + } + .background(NavigationBarConfigurator()) + .refreshable { viewModel.send(.refresh) } + .onAppear { viewModel.send(.onAppear) } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } + } + } + + private var summarySection: some View { + Section { + ScrollView(.horizontal) { + HStack(spacing: 12) { + SummaryCard( + title: "남은 일", + value: viewModel.remainingCount, + accentColor: .blue + ) + SummaryCard( + title: "집중", + value: viewModel.focusedCount, + accentColor: .orange + ) + SummaryCard( + title: "지연", + value: viewModel.overdueCount, + accentColor: .red + ) + SummaryCard( + title: "7일 내", + value: viewModel.dueSoonCount, + accentColor: .green + ) + } + } + .scrollIndicators(.never) + .contentMargins(.horizontal, 16) + } + .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) + } + + private var emptySection: some View { + Section { + VStack(spacing: 8) { + Text("남아 있는 Todo가 없습니다.") + .foregroundStyle(.primary) + Text("완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } + } + + @ViewBuilder + private func todoSection(_ title: String, items: [TodayTodoItem]) -> some View { + if !items.isEmpty { + Section { + ForEach(items) { item in + NavigationLink(value: Path.detail(item.id)) { + TodayTodoRow(item: item) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + viewModel.send(.togglePinned(item)) + } label: { + Image(systemName: item.isPinned ? "star.slash" : "star.fill") + } + .tint(.orange) + + Button { + viewModel.send(.completeTodo(item)) + } label: { + Image(systemName: "checkmark") + } + .tint(.green) + } + } + } header: { + Text(title) + .listRowInsets(EdgeInsets()) + } + } + } + + private enum Path: Hashable { + case detail(String) + } +} + +private struct SummaryCard: View { + let title: String + let value: Int + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(value)") + .font(.title2.bold()) + .foregroundStyle(Color(.label)) + } + .frame(width: 96, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(accentColor.opacity(0.12)) + ) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(accentColor.opacity(0.18), lineWidth: 1) + } + } +} + +private struct TodayTodoRow: View { + private let calendar = Calendar.current + let item: TodayTodoItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: item.kind.symbolName) + .foregroundStyle(item.kind.color) + .frame(width: 18) + Text(item.title) + .font(.headline) + .foregroundStyle(Color(.label)) + .lineLimit(1) + Spacer() + } + + HStack(spacing: 8) { + Text(item.kind.localizedName) + .font(.caption.weight(.semibold)) + .foregroundStyle(item.kind.color) + + if let dueDate { + Text(dueDate.text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(dueDate.textColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(dueDate.backgroundColor) + ) + } + } + + if !item.tags.isEmpty { + TagLayout(lineLimit: 1) { + ForEach(item.tags, id: \.self) { tagText in + Tag(tagText, isEditing: false) + } + } + } + } + } + + private var dueDate: DueDateBadge? { + guard let date = item.dueDate else { return nil } + let today = calendar.startOfDay(for: Date()) + let dueDay = calendar.startOfDay(for: date) + + if dueDay < today { + return DueDateBadge( + text: "기한 지남", + textColor: .red, + backgroundColor: Color.red.opacity(0.12) + ) + } + + let formatted = date.formatted(date: .abbreviated, time: .omitted) + return DueDateBadge( + text: formatted, + textColor: .blue, + backgroundColor: Color.blue.opacity(0.12) + ) + } + + private struct DueDateBadge { + let text: String + let textColor: Color + let backgroundColor: Color + } +} From 83a50bbda2174c4d2bd24eb64311b142f69d082f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 15:49:34 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EB=A1=9C=EC=BB=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EB=8C=80=EC=8B=A0=20=EC=84=9C=EB=B2=84=EC=97=90=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EB=90=9C=20=ED=98=95=ED=83=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=ED=95=B4=EC=84=9C=20=EB=B0=9B=EC=95=84?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/DTO/TodoCursorDTO.swift | 3 +- DevLog/Data/Mapper/TodoMapping.swift | 6 +- DevLog/Domain/Entity/TodoCursor.swift | 3 +- DevLog/Domain/Entity/TodoQuery.swift | 12 +++ DevLog/Infra/Service/TodoService.swift | 98 +++++++++++++++---- .../ViewModel/TodayViewModel.swift | 55 +++++------ .../ViewModel/TodoListViewModel.swift | 2 + 7 files changed, 126 insertions(+), 53 deletions(-) diff --git a/DevLog/Data/DTO/TodoCursorDTO.swift b/DevLog/Data/DTO/TodoCursorDTO.swift index 09abf1fd..d7fb22d5 100644 --- a/DevLog/Data/DTO/TodoCursorDTO.swift +++ b/DevLog/Data/DTO/TodoCursorDTO.swift @@ -8,6 +8,7 @@ import Foundation struct TodoCursorDTO { - let orderedAt: Date + let primarySortDate: Date? + let secondarySortDate: Date? let documentID: String } diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index e7244626..26a9a383 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -50,14 +50,16 @@ extension TodoResponse { extension TodoCursorDTO { func toDomain() -> TodoCursor { TodoCursor( - orderedAt: orderedAt, + primarySortDate: primarySortDate, + secondarySortDate: secondarySortDate, documentID: documentID ) } static func fromDomain(_ cursor: TodoCursor) -> Self { TodoCursorDTO( - orderedAt: cursor.orderedAt, + primarySortDate: cursor.primarySortDate, + secondarySortDate: cursor.secondarySortDate, documentID: cursor.documentID ) } diff --git a/DevLog/Domain/Entity/TodoCursor.swift b/DevLog/Domain/Entity/TodoCursor.swift index 552cd98f..3dd172ca 100644 --- a/DevLog/Domain/Entity/TodoCursor.swift +++ b/DevLog/Domain/Entity/TodoCursor.swift @@ -8,6 +8,7 @@ import Foundation struct TodoCursor { - let orderedAt: Date + let primarySortDate: Date? + let secondarySortDate: Date? let documentID: String } diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index 10689a86..57e4d830 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -11,6 +11,7 @@ struct TodoQuery: Equatable { enum SortTarget: Equatable, Hashable { case createdAt case updatedAt + case dueDate var fieldName: String { switch self { @@ -18,6 +19,8 @@ struct TodoQuery: Equatable { return "createdAt" case .updatedAt: return "updatedAt" + case .dueDate: + return "dueDate" } } } @@ -48,10 +51,17 @@ struct TodoQuery: Equatable { } } + enum DueDateFilter: Equatable, Hashable { + case all + case withDueDate + case withoutDueDate + } + var kind: TodoKind? var keyword: String? var isPinned: Bool? var completionFilter: CompletionFilter + var dueDateFilter: DueDateFilter var createdAtFrom: Date? var createdAtTo: Date? var sortTarget: SortTarget @@ -64,6 +74,7 @@ struct TodoQuery: Equatable { keyword: String? = nil, isPinned: Bool? = nil, completionFilter: CompletionFilter = .all, + dueDateFilter: DueDateFilter = .all, createdAtFrom: Date? = nil, createdAtTo: Date? = nil, sortTarget: SortTarget = .createdAt, @@ -75,6 +86,7 @@ struct TodoQuery: Equatable { self.keyword = keyword self.isPinned = isPinned self.completionFilter = completionFilter + self.dueDateFilter = dueDateFilter self.createdAtFrom = createdAtFrom self.createdAtTo = createdAtTo self.sortTarget = sortTarget diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index f6df99b8..021d8525 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -27,6 +27,7 @@ final class TodoService { query.kind != nil ? "kind=\(query.kind!.rawValue)" : nil, query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, query.completionFilter.isCompletedValue != nil ? "completed=\(query.completionFilter.isCompletedValue!)" : nil, + query.dueDateFilter != .all ? "dueDateFilter=\(query.dueDateFilter)" : nil, query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil, query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil, "pageSize=\(query.pageSize)", @@ -35,10 +36,7 @@ final class TodoService { ] logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") - var firestoreQuery: Query = store - .collection("users/\(uid)/todoLists/") - .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) - .order(by: FieldPath.documentID()) + var firestoreQuery: Query = makeOrderedQuery(uid: uid, query: query) if let kind = query.kind { firestoreQuery = firestoreQuery.whereField("kind", isEqualTo: kind.rawValue) @@ -52,6 +50,18 @@ final class TodoService { firestoreQuery = firestoreQuery.whereField("isCompleted", isEqualTo: isCompleted) } + switch query.dueDateFilter { + case .all: + break + case .withDueDate: + firestoreQuery = firestoreQuery.whereField( + "dueDate", + isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) + ) + case .withoutDueDate: + firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) + } + if let createdAtFrom = query.createdAtFrom { firestoreQuery = firestoreQuery.whereField( "createdAt", @@ -74,10 +84,7 @@ final class TodoService { while true { var pageQuery = firestoreQuery if let pageCursor { - pageQuery = pageQuery.start(after: [ - Timestamp(date: pageCursor.orderedAt), - pageCursor.documentID - ]) + pageQuery = pageQuery.start(after: cursorValues(for: query, cursor: pageCursor)) } pageQuery = pageQuery.limit(to: query.pageSize) @@ -91,7 +98,7 @@ final class TodoService { guard let lastDocument = snapshot.documents.last, let nextCursor = makeCursor( from: lastDocument, - orderField: query.sortTarget.fieldName + query: query ) else { break } @@ -103,17 +110,14 @@ final class TodoService { } if let cursor { - firestoreQuery = firestoreQuery.start(after: [ - Timestamp(date: cursor.orderedAt), - cursor.documentID - ]) + firestoreQuery = firestoreQuery.start(after: cursorValues(for: query, cursor: cursor)) } firestoreQuery = firestoreQuery.limit(to: query.pageSize) let snapshot = try await firestoreQuery.getDocuments() let items = snapshot.documents.compactMap { makeResponse(from: $0) } let nextCursor = snapshot.documents.last.flatMap { - makeCursor(from: $0, orderField: query.sortTarget.fieldName) + makeCursor(from: $0, query: query) } return TodoPageResponse(items: items, nextCursor: nextCursor) @@ -195,16 +199,76 @@ final class TodoService { } private extension TodoService { + func makeOrderedQuery(uid: String, query: TodoQuery) -> Query { + let collection = store.collection("users/\(uid)/todoLists/") + + switch query.sortTarget { + case .dueDate: + return collection + .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) + .order(by: "updatedAt", descending: true) + .order(by: FieldPath.documentID()) + case .createdAt, .updatedAt: + return collection + .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) + .order(by: FieldPath.documentID()) + } + } + + func cursorValues( + for query: TodoQuery, + cursor: TodoCursorDTO + ) -> [Any] { + let primaryValue: Any = cursor.primarySortDate.map { Timestamp(date: $0) } ?? NSNull() + + switch query.sortTarget { + case .dueDate: + guard let secondarySortDate = cursor.secondarySortDate else { + return [primaryValue, cursor.documentID] + } + return [ + primaryValue, + Timestamp(date: secondarySortDate), + cursor.documentID + ] + case .createdAt, .updatedAt: + return [ + primaryValue, + cursor.documentID + ] + } + } + func makeCursor( from document: QueryDocumentSnapshot, - orderField: String + query: TodoQuery ) -> TodoCursorDTO? { - guard let orderedAt = document.data()[orderField] as? Timestamp else { + let data = document.data() + let orderField = query.sortTarget.fieldName + let primarySortDate: Date? + let secondarySortDate: Date? + + if let timestamp = data[orderField] as? Timestamp { + primarySortDate = timestamp.dateValue() + } else if data[orderField] is NSNull { + primarySortDate = nil + } else { return nil } + switch query.sortTarget { + case .dueDate: + guard let updatedAt = data["updatedAt"] as? Timestamp else { + return nil + } + secondarySortDate = updatedAt.dateValue() + case .createdAt, .updatedAt: + secondarySortDate = nil + } + return TodoCursorDTO( - orderedAt: orderedAt.dateValue(), + primarySortDate: primarySortDate, + secondarySortDate: secondarySortDate, documentID: document.documentID ) } diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 7aa21d6e..0a2f1755 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -69,12 +69,13 @@ final class TodayViewModel: Store { state.todos.filter(isDueSoon).count } - var sections: [SectionContent] {[ - ("집중할 일", sortedScheduledItems(state.todos.filter(\.isPinned))), - ("지난 마감", sortedScheduledItems(state.todos.filter { !$0.isPinned && isOverdue($0) })), - ("\(upcomingWindowDays)일 내 일정", sortedScheduledItems(state.todos.filter { !$0.isPinned && isDueSoon($0) })), - ("나중 일정", sortedScheduledItems(state.todos.filter { !$0.isPinned && isScheduledLater($0) })), - ("일정 미정", unscheduledItems) + var sections: [SectionContent] { + [ + ("집중할 일", state.todos.filter(\.isPinned)), + ("지난 마감", state.todos.filter { !$0.isPinned && isOverdue($0) }), + ("\(upcomingWindowDays)일 내 일정", state.todos.filter { !$0.isPinned && isDueSoon($0) }), + ("나중 일정", state.todos.filter { !$0.isPinned && isScheduledLater($0) }), + ("일정 미정", state.todos.filter { !$0.isPinned && $0.dueDate == nil }) ] .filter { !$0.items.isEmpty } } @@ -103,9 +104,21 @@ final class TodayViewModel: Store { do { defer { send(.setLoading(false)) } send(.setLoading(true)) - let page = try await fetchTodosUseCase.execute( + async let todosWithDueDatePage = fetchTodosUseCase.execute( TodoQuery( completionFilter: .incomplete, + dueDateFilter: .withDueDate, + sortTarget: .dueDate, + sortOrder: .oldest, + pageSize: pageSize, + fetchAllPages: true + ), + cursor: nil + ) + async let todosWithoutDueDatePage = fetchTodosUseCase.execute( + TodoQuery( + completionFilter: .incomplete, + dueDateFilter: .withoutDueDate, sortTarget: .updatedAt, sortOrder: .latest, pageSize: pageSize, @@ -113,7 +126,9 @@ final class TodayViewModel: Store { ), cursor: nil ) - send(.fetchTodos(page.items.map { TodayTodoItem(from: $0) })) + let todosWithDueDate = try await todosWithDueDatePage.items.map { TodayTodoItem(from: $0) } + let todosWithoutDueDate = try await todosWithoutDueDatePage.items.map { TodayTodoItem(from: $0) } + send(.fetchTodos(todosWithDueDate + todosWithoutDueDate)) } catch { send(.setAlert(true)) } @@ -232,28 +247,4 @@ private extension TodayViewModel { let dueDay = calendar.startOfDay(for: dueDate) return windowEnd < dueDay } - - func sortedScheduledItems(_ items: [TodayTodoItem]) -> [TodayTodoItem] { - items.sorted { lhs, rhs in - switch (lhs.dueDate, rhs.dueDate) { - case let (left?, right?): - let leftDay = calendar.startOfDay(for: left) - let rightDay = calendar.startOfDay(for: right) - if leftDay != rightDay { return leftDay < rightDay } - return lhs.updatedAt > rhs.updatedAt - case (.some, .none): - return true - case (.none, .some): - return false - case (.none, .none): - return lhs.updatedAt > rhs.updatedAt - } - } - } - - var unscheduledItems: [TodayTodoItem] { - state.todos - .filter { !$0.isPinned && $0.dueDate == nil } - .sorted { $0.updatedAt > $1.updatedAt } - } } diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 24134f88..29d06d50 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -419,6 +419,8 @@ extension TodoQuery.SortTarget { return "생성" case .updatedAt: return "수정" + case .dueDate: + return "마감" } } } From 86c150de1343b9c71ef9453a10b25f602bacd3f7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 16:26:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20SummaryCard=EB=A5=BC=20=ED=83=AD?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=B4=EC=84=9C=20=EA=B0=81=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=ED=95=B4=EB=8B=B9=ED=95=98=EB=8A=94=20Tod?= =?UTF-8?q?o=EB=A5=BC=20=EC=95=84=EB=9E=98=EC=97=90=EC=84=9C=20=EB=B3=BC?= =?UTF-8?q?=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodayViewModel.swift | 74 ++++++++++++++++- DevLog/Resource/Localizable.xcstrings | 6 -- DevLog/UI/Today/TodayView.swift | 81 ++++++++++++------- 3 files changed, 124 insertions(+), 37 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 0a2f1755..011a667b 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -10,6 +10,13 @@ import Foundation @Observable final class TodayViewModel: Store { typealias SectionContent = (title: String, items: [TodayTodoItem]) + + enum SummaryScope: Hashable, CaseIterable { + case all + case focused + case overdue + case dueSoon + } struct State: Equatable { var todos: [TodayTodoItem] = [] @@ -17,11 +24,13 @@ final class TodayViewModel: Store { var showAlert: Bool = false var alertTitle: String = "" var alertMessage: String = "" + var selectedSummaryScope: SummaryScope = .all } enum Action { case refresh case setAlert(Bool) + case setSummaryScope(SummaryScope) case completeTodo(TodayTodoItem) case togglePinned(TodayTodoItem) case onAppear @@ -69,15 +78,68 @@ final class TodayViewModel: Store { state.todos.filter(isDueSoon).count } + var selectedSummaryScope: SummaryScope { + state.selectedSummaryScope + } + + func summaryValue(for scope: SummaryScope) -> Int { + switch scope { + case .all: + return remainingCount + case .focused: + return focusedCount + case .overdue: + return overdueCount + case .dueSoon: + return dueSoonCount + } + } + + var emptyStateTitle: String { + switch state.selectedSummaryScope { + case .all: + return "남아 있는 Todo가 없습니다." + case .focused: + return "집중할 일이 없습니다." + case .overdue: + return "지난 마감 Todo가 없습니다." + case .dueSoon: + return "\(upcomingWindowDays)일 내 일정이 없습니다." + } + } + + var emptyStateMessage: String { + switch state.selectedSummaryScope { + case .all: + return "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." + case .focused: + return "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." + case .overdue: + return "지금은 기한이 지난 Todo가 없습니다." + case .dueSoon: + return "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." + } + } + var sections: [SectionContent] { - [ + let allSections: [SectionContent] = [ ("집중할 일", state.todos.filter(\.isPinned)), ("지난 마감", state.todos.filter { !$0.isPinned && isOverdue($0) }), ("\(upcomingWindowDays)일 내 일정", state.todos.filter { !$0.isPinned && isDueSoon($0) }), ("나중 일정", state.todos.filter { !$0.isPinned && isScheduledLater($0) }), ("일정 미정", state.todos.filter { !$0.isPinned && $0.dueDate == nil }) ] - .filter { !$0.items.isEmpty } + + switch state.selectedSummaryScope { + case .all: + return allSections.filter { !$0.items.isEmpty } + case .focused: + return allSections.filter { $0.title == "집중할 일" && !$0.items.isEmpty } + case .overdue: + return allSections.filter { $0.title == "지난 마감" && !$0.items.isEmpty } + case .dueSoon: + return allSections.filter { $0.title == "\(upcomingWindowDays)일 내 일정" && !$0.items.isEmpty } + } } func reduce(with action: Action) -> [SideEffect] { @@ -85,7 +147,7 @@ final class TodayViewModel: Store { var effects: [SideEffect] = [] switch action { - case .refresh, .setAlert, .completeTodo, .togglePinned: + case .refresh, .setAlert, .setSummaryScope, .completeTodo, .togglePinned: effects = reduceByUser(action, state: &state) case .onAppear: effects = reduceByView(action, state: &state) @@ -174,6 +236,12 @@ private extension TodayViewModel { return [.fetchTodos] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) + case .setSummaryScope(let scope): + if state.selectedSummaryScope == scope, scope != .all { + state.selectedSummaryScope = .all + } else { + state.selectedSummaryScope = scope + } case .completeTodo(let item): return [.completeTodo(item)] case .togglePinned(let item): diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 525b46de..d043ba03 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -263,9 +263,6 @@ }, "기간" : { - }, - "남아 있는 Todo가 없습니다." : { - }, "더보기" : { @@ -347,9 +344,6 @@ }, "완료 상태" : { - }, - "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." : { - }, "완료일" : { diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 08a04653..97faa4f3 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -65,26 +65,21 @@ struct TodayView: View { Section { ScrollView(.horizontal) { HStack(spacing: 12) { - SummaryCard( - title: "남은 일", - value: viewModel.remainingCount, - accentColor: .blue - ) - SummaryCard( - title: "집중", - value: viewModel.focusedCount, - accentColor: .orange - ) - SummaryCard( - title: "지연", - value: viewModel.overdueCount, - accentColor: .red - ) - SummaryCard( - title: "7일 내", - value: viewModel.dueSoonCount, - accentColor: .green - ) + ForEach(TodayViewModel.SummaryScope.allCases, id: \.self) { scope in + Button { + withAnimation(.easeInOut) { + viewModel.send(.setSummaryScope(scope)) + } + } label: { + SummaryCard( + title: scope.title, + value: viewModel.summaryValue(for: scope), + accentColor: scope.accentColor, + isSelected: viewModel.selectedSummaryScope == scope + ) + } + .buttonStyle(.plain) + } } } .scrollIndicators(.never) @@ -96,9 +91,9 @@ struct TodayView: View { private var emptySection: some View { Section { VStack(spacing: 8) { - Text("남아 있는 Todo가 없습니다.") + Text(viewModel.emptyStateTitle) .foregroundStyle(.primary) - Text("완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다.") + Text(viewModel.emptyStateMessage) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -145,16 +140,45 @@ struct TodayView: View { } } +private extension TodayViewModel.SummaryScope { + var title: String { + switch self { + case .all: + return "남은 일" + case .focused: + return "집중" + case .overdue: + return "지연" + case .dueSoon: + return "7일 내" + } + } + + var accentColor: Color { + switch self { + case .all: + return .blue + case .focused: + return .orange + case .overdue: + return .red + case .dueSoon: + return .green + } + } +} + private struct SummaryCard: View { let title: String let value: Int let accentColor: Color + let isSelected: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { Text(title) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(isSelected ? accentColor : .secondary) Text("\(value)") .font(.title2.bold()) .foregroundStyle(Color(.label)) @@ -163,12 +187,13 @@ private struct SummaryCard: View { .padding(14) .background( RoundedRectangle(cornerRadius: 16) - .fill(accentColor.opacity(0.12)) + .fill(isSelected ? accentColor.opacity(0.2) : accentColor.opacity(0.12)) + .strokeBorder( + isSelected ? accentColor.opacity(0.55) : accentColor.opacity(0.18), + lineWidth: isSelected ? 1.5 : 1 + ) ) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(accentColor.opacity(0.18), lineWidth: 1) - } + .scaleEffect(isSelected ? 1 : 0.98) } } From 32de4b91217cba8184372eb3ce95ab1628395f6f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 17:14:28 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=9A=B0=EC=B8=A1=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=EC=97=90=20=ED=88=B4=EB=B0=94=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EC=97=AC=20=EB=B3=B4=EA=B3=A0=EC=8B=B6?= =?UTF-8?q?=EC=9D=80=20Todo=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A7=8C=20?= =?UTF-8?q?=EB=B3=B4=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DomainAssembler.swift | 8 ++ .../UserPreferencesRepositoryImpl.swift | 21 +++ .../Domain/Entity/TodayDisplayOptions.swift | 29 ++++ .../Protocol/UserPreferencesRepository.swift | 3 + .../FetchTodayDisplayOptionsUseCase.swift | 10 ++ .../FetchTodayDisplayOptionsUseCaseImpl.swift | 18 +++ .../UpdateTodayDisplayOptionsUseCase.swift | 10 ++ ...UpdateTodayDisplayOptionsUseCaseImpl.swift | 18 +++ .../ViewModel/TodayViewModel.swift | 130 +++++++++--------- DevLog/Resource/Localizable.xcstrings | 6 + DevLog/UI/Common/MainView.swift | 4 +- DevLog/UI/Today/TodayView.swift | 111 +++++++++++++-- 12 files changed, 293 insertions(+), 75 deletions(-) create mode 100644 DevLog/Domain/Entity/TodayDisplayOptions.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index ab6d86bd..734ff1b7 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -154,5 +154,13 @@ private extension DomainAssembler { container.register(UpdateProfileHeatmapActivityTypesUseCase.self) { UpdateProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } + + container.register(FetchTodayDisplayOptionsUseCase.self) { + FetchTodayDisplayOptionsUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(UpdateTodayDisplayOptionsUseCase.self) { + UpdateTodayDisplayOptionsUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } } } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index 95bde867..7c6669ec 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -17,6 +17,8 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { static let pushTimeFilter = "PushNotification.timeFilter" static let pushUnreadOnly = "PushNotification.showUnreadOnly" static let profileHeatmapActivityTypes = "Profile.heatmap.activityTypes" + static let todayDueDateVisibility = "Today.dueDateVisibility" + static let todayFocusVisibility = "Today.focusVisibility" } private let store: UserDefaultsStore @@ -101,4 +103,23 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setProfileHeatmapActivityTypes(_ activityTypes: [String]) { store.setStringArray(activityTypes, forKey: Key.profileHeatmapActivityTypes) } + + func todayDisplayOptions() -> TodayDisplayOptions { + let dueDateVisibilityRawValue = store.string(forKey: Key.todayDueDateVisibility) + let focusVisibilityRawValue = store.string(forKey: Key.todayFocusVisibility) + + return TodayDisplayOptions( + dueDateVisibility: TodayDisplayOptions.DueDateVisibility( + rawValue: dueDateVisibilityRawValue ?? "" + ) ?? .all, + focusVisibility: TodayDisplayOptions.FocusVisibility( + rawValue: focusVisibilityRawValue ?? "" + ) ?? .all + ) + } + + func setTodayDisplayOptions(_ options: TodayDisplayOptions) { + store.setString(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility) + store.setString(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility) + } } diff --git a/DevLog/Domain/Entity/TodayDisplayOptions.swift b/DevLog/Domain/Entity/TodayDisplayOptions.swift new file mode 100644 index 00000000..af6d9318 --- /dev/null +++ b/DevLog/Domain/Entity/TodayDisplayOptions.swift @@ -0,0 +1,29 @@ +// +// TodayDisplayOptions.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +struct TodayDisplayOptions: Equatable { + enum DueDateVisibility: String, CaseIterable, Equatable { + case all + case withDueDateOnly + case withoutDueDateOnly + } + + enum FocusVisibility: String, CaseIterable, Equatable { + case all + case focusedOnly + } + + var dueDateVisibility: DueDateVisibility + var focusVisibility: FocusVisibility + + static let `default` = TodayDisplayOptions( + dueDateVisibility: .all, + focusVisibility: .all + ) +} diff --git a/DevLog/Domain/Protocol/UserPreferencesRepository.swift b/DevLog/Domain/Protocol/UserPreferencesRepository.swift index ba27fb45..2f0a700e 100644 --- a/DevLog/Domain/Protocol/UserPreferencesRepository.swift +++ b/DevLog/Domain/Protocol/UserPreferencesRepository.swift @@ -30,4 +30,7 @@ protocol UserPreferencesRepository { func profileHeatmapActivityTypes() -> [String] func setProfileHeatmapActivityTypes(_ activityTypes: [String]) + + func todayDisplayOptions() -> TodayDisplayOptions + func setTodayDisplayOptions(_ options: TodayDisplayOptions) } diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift new file mode 100644 index 00000000..2bad555c --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchTodayDisplayOptionsUseCase.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +protocol FetchTodayDisplayOptionsUseCase { + func execute() -> TodayDisplayOptions +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift new file mode 100644 index 00000000..610c796f --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/FetchTodayDisplayOptionsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchTodayDisplayOptionsUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +final class FetchTodayDisplayOptionsUseCaseImpl: FetchTodayDisplayOptionsUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> TodayDisplayOptions { + repository.todayDisplayOptions() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift new file mode 100644 index 00000000..64f91b08 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateTodayDisplayOptionsUseCase.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +protocol UpdateTodayDisplayOptionsUseCase { + func execute(_ options: TodayDisplayOptions) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift new file mode 100644 index 00000000..787ce9f7 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Today/UpdateTodayDisplayOptionsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateTodayDisplayOptionsUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +final class UpdateTodayDisplayOptionsUseCaseImpl: UpdateTodayDisplayOptionsUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ options: TodayDisplayOptions) { + repository.setTodayDisplayOptions(options) + } +} diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 011a667b..7bc1562f 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -9,8 +9,6 @@ import Foundation @Observable final class TodayViewModel: Store { - typealias SectionContent = (title: String, items: [TodayTodoItem]) - enum SummaryScope: Hashable, CaseIterable { case all case focused @@ -18,6 +16,12 @@ final class TodayViewModel: Store { case dueSoon } + struct SectionContent: Identifiable, Equatable { + var id: String { title } + let title: String + let items: [TodayTodoItem] + } + struct State: Equatable { var todos: [TodayTodoItem] = [] var isLoading: Bool = false @@ -25,12 +29,16 @@ final class TodayViewModel: Store { var alertTitle: String = "" var alertMessage: String = "" var selectedSummaryScope: SummaryScope = .all + var displayOptions: TodayDisplayOptions = .default } enum Action { case refresh case setAlert(Bool) case setSummaryScope(SummaryScope) + case setDueDateVisibility(TodayDisplayOptions.DueDateVisibility) + case setFocusVisibility(TodayDisplayOptions.FocusVisibility) + case resetDisplayOptions case completeTodo(TodayTodoItem) case togglePinned(TodayTodoItem) case onAppear @@ -53,81 +61,29 @@ final class TodayViewModel: Store { private let fetchTodosUseCase: FetchTodosUseCase private let fetchTodoByIDUseCase: FetchTodoByIDUseCase private let upsertTodoUseCase: UpsertTodoUseCase + private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase init( fetchTodosUseCase: FetchTodosUseCase, fetchTodoByIDUseCase: FetchTodoByIDUseCase, - upsertTodoUseCase: UpsertTodoUseCase + upsertTodoUseCase: UpsertTodoUseCase, + fetchTodayDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, + updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase ) { self.fetchTodosUseCase = fetchTodosUseCase self.fetchTodoByIDUseCase = fetchTodoByIDUseCase self.upsertTodoUseCase = upsertTodoUseCase - } - - var remainingCount: Int { state.todos.count } - - var focusedCount: Int { - state.todos.filter(\.isPinned).count - } - - var overdueCount: Int { - state.todos.filter(isOverdue).count - } - - var dueSoonCount: Int { - state.todos.filter(isDueSoon).count - } - - var selectedSummaryScope: SummaryScope { - state.selectedSummaryScope - } - - func summaryValue(for scope: SummaryScope) -> Int { - switch scope { - case .all: - return remainingCount - case .focused: - return focusedCount - case .overdue: - return overdueCount - case .dueSoon: - return dueSoonCount - } - } - - var emptyStateTitle: String { - switch state.selectedSummaryScope { - case .all: - return "남아 있는 Todo가 없습니다." - case .focused: - return "집중할 일이 없습니다." - case .overdue: - return "지난 마감 Todo가 없습니다." - case .dueSoon: - return "\(upcomingWindowDays)일 내 일정이 없습니다." - } - } - - var emptyStateMessage: String { - switch state.selectedSummaryScope { - case .all: - return "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." - case .focused: - return "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." - case .overdue: - return "지금은 기한이 지난 Todo가 없습니다." - case .dueSoon: - return "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." - } + self.updateTodayDisplayOptionsUseCase = updateTodayDisplayOptionsUseCase + self.state.displayOptions = fetchTodayDisplayOptionsUseCase.execute() } var sections: [SectionContent] { let allSections: [SectionContent] = [ - ("집중할 일", state.todos.filter(\.isPinned)), - ("지난 마감", state.todos.filter { !$0.isPinned && isOverdue($0) }), - ("\(upcomingWindowDays)일 내 일정", state.todos.filter { !$0.isPinned && isDueSoon($0) }), - ("나중 일정", state.todos.filter { !$0.isPinned && isScheduledLater($0) }), - ("일정 미정", state.todos.filter { !$0.isPinned && $0.dueDate == nil }) + SectionContent(title: "집중할 일", items: displayedTodos.filter(\.isPinned)), + SectionContent(title: "지난 마감", items: displayedTodos.filter { !$0.isPinned && isOverdue($0) }), + SectionContent(title: "\(upcomingWindowDays)일 내 일정", items: displayedTodos.filter { !$0.isPinned && isDueSoon($0) }), + SectionContent(title: "나중 일정", items: displayedTodos.filter { !$0.isPinned && isScheduledLater($0) }), + SectionContent(title: "일정 미정", items: displayedTodos.filter { !$0.isPinned && $0.dueDate == nil }) ] switch state.selectedSummaryScope { @@ -142,12 +98,26 @@ final class TodayViewModel: Store { } } + func summaryValue(for scope: SummaryScope) -> Int { + switch scope { + case .all: + return displayedTodos.count + case .focused: + return displayedTodos.filter(\.isPinned).count + case .overdue: + return displayedTodos.filter(isOverdue).count + case .dueSoon: + return displayedTodos.filter(isDueSoon).count + } + } + func reduce(with action: Action) -> [SideEffect] { var state = self.state var effects: [SideEffect] = [] switch action { - case .refresh, .setAlert, .setSummaryScope, .completeTodo, .togglePinned: + case .refresh, .setAlert, .setSummaryScope, .setDueDateVisibility, .setFocusVisibility, + .resetDisplayOptions, .completeTodo, .togglePinned: effects = reduceByUser(action, state: &state) case .onAppear: effects = reduceByView(action, state: &state) @@ -242,6 +212,15 @@ private extension TodayViewModel { } else { state.selectedSummaryScope = scope } + case .setDueDateVisibility(let visibility): + state.displayOptions.dueDateVisibility = visibility + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) + case .setFocusVisibility(let visibility): + state.displayOptions.focusVisibility = visibility + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) + case .resetDisplayOptions: + state.displayOptions = .default + updateTodayDisplayOptionsUseCase.execute(state.displayOptions) case .completeTodo(let item): return [.completeTodo(item)] case .togglePinned(let item): @@ -291,6 +270,25 @@ private extension TodayViewModel { state.showAlert = isPresented } + var displayedTodos: [TodayTodoItem] { + let dueDateFilteredTodos: [TodayTodoItem] + switch state.displayOptions.dueDateVisibility { + case .all: + dueDateFilteredTodos = state.todos + case .withDueDateOnly: + dueDateFilteredTodos = state.todos.filter { $0.dueDate != nil } + case .withoutDueDateOnly: + dueDateFilteredTodos = state.todos.filter { $0.dueDate == nil } + } + + switch state.displayOptions.focusVisibility { + case .all: + return dueDateFilteredTodos + case .focusedOnly: + return dueDateFilteredTodos.filter(\.isPinned) + } + } + func isOverdue(_ item: TodayTodoItem) -> Bool { guard let dueDate = item.dueDate else { return false } return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d043ba03..e7ca8fe3 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -293,6 +293,9 @@ }, "베타 테스트 참여" : { + }, + "보기 범위" : { + }, "분기별 활동 히트맵" : { @@ -387,6 +390,9 @@ }, "중요 표시" : { + }, + "집중 표시" : { + }, "최근 검색" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index e5e4789a..0d24daf4 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -26,7 +26,9 @@ struct MainView: View { TodayView(viewModel: TodayViewModel( fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchTodoByIDUseCase: container.resolve(FetchTodoByIDUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self) + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), + updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) )) .tabItem { Image(systemName: "sun.max.fill") diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 97faa4f3..09005af8 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -13,22 +13,20 @@ struct TodayView: View { @State var viewModel: TodayViewModel var body: some View { - let sections = viewModel.sections - NavigationStack(path: $router.path) { List { summarySection - if sections.isEmpty, !viewModel.state.isLoading { + if viewModel.sections.isEmpty, !viewModel.state.isLoading { emptySection } else { - ForEach(Array(sections.indices), id: \.self) { index in - let section = sections[index] + ForEach(viewModel.sections) { section in todoSection(section.title, items: section.items) } } } .listStyle(.insetGrouped) .navigationTitle("오늘") + .toolbar { toolbarContent } .navigationDestination(for: Path.self) { path in switch path { case .detail(let todoID): @@ -75,7 +73,7 @@ struct TodayView: View { title: scope.title, value: viewModel.summaryValue(for: scope), accentColor: scope.accentColor, - isSelected: viewModel.selectedSummaryScope == scope + isSelected: viewModel.state.selectedSummaryScope == scope ) } .buttonStyle(.plain) @@ -88,12 +86,46 @@ struct TodayView: View { .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)) } + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker( + "보기 범위", + selection: Binding( + get: { viewModel.state.displayOptions.dueDateVisibility }, + set: { viewModel.send(.setDueDateVisibility($0)) } + ) + ) { + ForEach(TodayDisplayOptions.DueDateVisibility.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + + Picker( + "집중 표시", + selection: Binding( + get: { viewModel.state.displayOptions.focusVisibility }, + set: { viewModel.send(.setFocusVisibility($0)) } + ) + ) { + ForEach(TodayDisplayOptions.FocusVisibility.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + } label: { + let options = viewModel.state.displayOptions + Image(systemName: "line.3.horizontal.decrease.circle\(options == .default ? "" : ".fill")") + } + } + } + private var emptySection: some View { Section { VStack(spacing: 8) { - Text(viewModel.emptyStateTitle) + Text(emptyStateTitle) .foregroundStyle(.primary) - Text(viewModel.emptyStateMessage) + Text(emptyStateMessage) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -138,6 +170,58 @@ struct TodayView: View { private enum Path: Hashable { case detail(String) } + + private var emptyStateTitle: String { + if viewModel.state.selectedSummaryScope == .all, viewModel.state.todos.isEmpty { + return "남아 있는 Todo가 없습니다." + } + if viewModel.state.selectedSummaryScope == .all, viewModel.sections.isEmpty { + return "선택한 보기 옵션에 맞는 Todo가 없습니다." + } + + switch viewModel.state.selectedSummaryScope { + case .all: + return "남아 있는 Todo가 없습니다." + case .focused: + return "집중할 일이 없습니다." + case .overdue: + return "지난 마감 Todo가 없습니다." + case .dueSoon: + return "7일 내 일정이 없습니다." + } + } + + private var emptyStateMessage: String { + if viewModel.state.selectedSummaryScope == .all, + !viewModel.state.todos.isEmpty, + viewModel.sections.isEmpty { + return "툴바에서 보기 범위를 조정하거나 전체 보기로 돌아가세요." + } + + switch viewModel.state.selectedSummaryScope { + case .all: + return "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." + case .focused: + return "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." + case .overdue: + return "지금은 기한이 지난 Todo가 없습니다." + case .dueSoon: + return "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." + } + } +} + +private extension TodayDisplayOptions.DueDateVisibility { + var title: String { + switch self { + case .all: + return "전체" + case .withDueDateOnly: + return "기한 있는 Todo만" + case .withoutDueDateOnly: + return "기한 없는 Todo만" + } + } } private extension TodayViewModel.SummaryScope { @@ -168,6 +252,17 @@ private extension TodayViewModel.SummaryScope { } } +private extension TodayDisplayOptions.FocusVisibility { + var title: String { + switch self { + case .all: + return "전체" + case .focusedOnly: + return "중요 표시만" + } + } +} + private struct SummaryCard: View { let title: String let value: Int From c8825455abf7ced4c57025621f625c8fe9a8bd74 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 17:46:48 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20iOS=2017=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A9=B4=20Menu=EC=97=90=EC=84=9C=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A7=8C=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B04?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Resource/Localizable.xcstrings | 5 +++- DevLog/UI/Today/TodayView.swift | 33 +++++++++++---------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index e7ca8fe3..33582fa8 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -391,7 +391,10 @@ "중요 표시" : { }, - "집중 표시" : { + "중요 표시만" : { + + }, + "중요 표시한 Todo만 표시됩니다." : { }, "최근 검색" : { diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 09005af8..2aa46532 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -102,16 +102,20 @@ struct TodayView: View { } } - Picker( - "집중 표시", - selection: Binding( - get: { viewModel.state.displayOptions.focusVisibility }, - set: { viewModel.send(.setFocusVisibility($0)) } + Toggle( + "중요 표시만", + isOn: Binding( + get: { viewModel.state.displayOptions.focusVisibility == .focusedOnly }, + set: { + viewModel.send(.setFocusVisibility($0 ? .focusedOnly : .all)) + } ) - ) { - ForEach(TodayDisplayOptions.FocusVisibility.allCases, id: \.self) { option in - Text(option.title).tag(option) - } + ) + .tint(.orange) + + if viewModel.state.displayOptions.focusVisibility == .focusedOnly { + Text("중요 표시한 Todo만 표시됩니다.") + .font(.caption) } } label: { let options = viewModel.state.displayOptions @@ -252,17 +256,6 @@ private extension TodayViewModel.SummaryScope { } } -private extension TodayDisplayOptions.FocusVisibility { - var title: String { - switch self { - case .all: - return "전체" - case .focusedOnly: - return "중요 표시만" - } - } -} - private struct SummaryCard: View { let title: String let value: Int From 069fb1ed6fc76955e81b8235aa6513d005589883 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 18:10:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20dueDate=EA=B0=80=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=90=9C=20=EC=BF=BC=EB=A6=AC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20secondary=EA=B0=80=20=EC=97=86=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=EB=B3=B4=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/TodoService.swift | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 021d8525..dd6f8c35 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -13,6 +13,7 @@ final class TodoService { private let encoder = Firestore.Encoder() private let logger = Logger(category: "TodoService") + // swiftlint:disable function_body_length func fetchTodos( _ query: TodoQuery, cursor: TodoCursorDTO? @@ -84,7 +85,11 @@ final class TodoService { while true { var pageQuery = firestoreQuery if let pageCursor { - pageQuery = pageQuery.start(after: cursorValues(for: query, cursor: pageCursor)) + guard let cursorValues = cursorValues(for: query, cursor: pageCursor) else { + logger.error("Failed to build cursor values for paginated todo fetch.") + break + } + pageQuery = pageQuery.start(after: cursorValues) } pageQuery = pageQuery.limit(to: query.pageSize) @@ -110,7 +115,11 @@ final class TodoService { } if let cursor { - firestoreQuery = firestoreQuery.start(after: cursorValues(for: query, cursor: cursor)) + guard let cursorValues = cursorValues(for: query, cursor: cursor) else { + logger.error("Failed to build cursor values for todo fetch.") + return TodoPageResponse(items: [], nextCursor: nil) + } + firestoreQuery = firestoreQuery.start(after: cursorValues) } firestoreQuery = firestoreQuery.limit(to: query.pageSize) @@ -134,6 +143,7 @@ final class TodoService { return TodoPageResponse(items: filtered, nextCursor: nil) } + // swiftlint:enable function_body_length func upsertTodo(request: TodoRequest) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } @@ -218,17 +228,15 @@ private extension TodoService { func cursorValues( for query: TodoQuery, cursor: TodoCursorDTO - ) -> [Any] { + ) -> [Any]? { let primaryValue: Any = cursor.primarySortDate.map { Timestamp(date: $0) } ?? NSNull() switch query.sortTarget { case .dueDate: - guard let secondarySortDate = cursor.secondarySortDate else { - return [primaryValue, cursor.documentID] - } + guard let sortDate = cursor.secondarySortDate else { return nil } return [ primaryValue, - Timestamp(date: secondarySortDate), + Timestamp(date: sortDate), cursor.documentID ] case .createdAt, .updatedAt: From fffa8a014c39fc29848f74282f65dba1fee498d3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 18:15:21 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=81=20=EC=A1=B0=EA=B1=B4=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=B4=20=EA=B0=81=EA=B0=81=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=8C,=20=ED=95=9C=EB=B2=88=EC=9D=98?= =?UTF-8?q?=20=EC=88=9C=ED=9A=8C=20=EB=8F=99=EC=95=88=20=EA=B0=81=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EA=B2=80=EC=82=AC=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=ED=95=84=ED=84=B0=EB=A7=81=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodayViewModel.swift | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 7bc1562f..94d1f23e 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -22,6 +22,14 @@ final class TodayViewModel: Store { let items: [TodayTodoItem] } + struct SectionBuckets { + var focused: [TodayTodoItem] = [] + var overdue: [TodayTodoItem] = [] + var dueSoon: [TodayTodoItem] = [] + var later: [TodayTodoItem] = [] + var unscheduled: [TodayTodoItem] = [] + } + struct State: Equatable { var todos: [TodayTodoItem] = [] var isLoading: Bool = false @@ -78,12 +86,13 @@ final class TodayViewModel: Store { } var sections: [SectionContent] { + let groupedItems = groupedSectionItems(from: displayedTodos) let allSections: [SectionContent] = [ - SectionContent(title: "집중할 일", items: displayedTodos.filter(\.isPinned)), - SectionContent(title: "지난 마감", items: displayedTodos.filter { !$0.isPinned && isOverdue($0) }), - SectionContent(title: "\(upcomingWindowDays)일 내 일정", items: displayedTodos.filter { !$0.isPinned && isDueSoon($0) }), - SectionContent(title: "나중 일정", items: displayedTodos.filter { !$0.isPinned && isScheduledLater($0) }), - SectionContent(title: "일정 미정", items: displayedTodos.filter { !$0.isPinned && $0.dueDate == nil }) + SectionContent(title: "집중할 일", items: groupedItems.focused), + SectionContent(title: "지난 마감", items: groupedItems.overdue), + SectionContent(title: "\(upcomingWindowDays)일 내 일정", items: groupedItems.dueSoon), + SectionContent(title: "나중 일정", items: groupedItems.later), + SectionContent(title: "일정 미정", items: groupedItems.unscheduled) ] switch state.selectedSummaryScope { @@ -289,6 +298,43 @@ private extension TodayViewModel { } } + func groupedSectionItems( + from items: [TodayTodoItem] + ) -> SectionBuckets { + let startOfToday = calendar.startOfDay(for: Date()) + guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { + return SectionBuckets( + focused: items.filter(\.isPinned), + unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } + ) + } + + var buckets = SectionBuckets() + + for item in items { + if item.isPinned { + buckets.focused.append(item) + continue + } + + guard let dueDate = item.dueDate else { + buckets.unscheduled.append(item) + continue + } + + let dueDay = calendar.startOfDay(for: dueDate) + if dueDay < startOfToday { + buckets.overdue.append(item) + } else if dueDay <= windowEnd { + buckets.dueSoon.append(item) + } else { + buckets.later.append(item) + } + } + + return buckets + } + func isOverdue(_ item: TodayTodoItem) -> Bool { guard let dueDate = item.dueDate else { return false } return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) @@ -303,14 +349,4 @@ private extension TodayViewModel { let dueDay = calendar.startOfDay(for: dueDate) return startOfToday <= dueDay && dueDay <= windowEnd } - - func isScheduledLater(_ item: TodayTodoItem) -> Bool { - guard let dueDate = item.dueDate else { return false } - let startOfToday = calendar.startOfDay(for: Date()) - guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { - return false - } - let dueDay = calendar.startOfDay(for: dueDate) - return windowEnd < dueDay - } } From 5aeddc4ac39f0056833bd46969d677015aa20725 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 18:18:51 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=8B=80=EA=B3=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=EC=97=90=EC=84=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Today/TodayView.swift | 65 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 2aa46532..8029be81 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -127,9 +127,9 @@ struct TodayView: View { private var emptySection: some View { Section { VStack(spacing: 8) { - Text(emptyStateTitle) + Text(emptyStateContent.title) .foregroundStyle(.primary) - Text(emptyStateMessage) + Text(emptyStateContent.message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -171,47 +171,44 @@ struct TodayView: View { } } - private enum Path: Hashable { - case detail(String) - } - - private var emptyStateTitle: String { - if viewModel.state.selectedSummaryScope == .all, viewModel.state.todos.isEmpty { - return "남아 있는 Todo가 없습니다." - } - if viewModel.state.selectedSummaryScope == .all, viewModel.sections.isEmpty { - return "선택한 보기 옵션에 맞는 Todo가 없습니다." - } - + private var emptyStateContent: EmptyStateContent { switch viewModel.state.selectedSummaryScope { case .all: - return "남아 있는 Todo가 없습니다." + if viewModel.state.todos.isEmpty { + return EmptyStateContent( + title: "남아 있는 Todo가 없습니다.", + message: "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." + ) + } + return EmptyStateContent( + title: "선택한 보기 옵션에 맞는 Todo가 없습니다.", + message: "툴바에서 보기 범위를 조정하거나 전체 보기로 돌아가세요." + ) case .focused: - return "집중할 일이 없습니다." + return EmptyStateContent( + title: "집중할 일이 없습니다.", + message: "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." + ) case .overdue: - return "지난 마감 Todo가 없습니다." + return EmptyStateContent( + title: "지난 마감 Todo가 없습니다.", + message: "지금은 기한이 지난 Todo가 없습니다." + ) case .dueSoon: - return "7일 내 일정이 없습니다." + return EmptyStateContent( + title: "7일 내 일정이 없습니다.", + message: "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." + ) } } - private var emptyStateMessage: String { - if viewModel.state.selectedSummaryScope == .all, - !viewModel.state.todos.isEmpty, - viewModel.sections.isEmpty { - return "툴바에서 보기 범위를 조정하거나 전체 보기로 돌아가세요." - } + private struct EmptyStateContent { + let title: String + let message: String + } - switch viewModel.state.selectedSummaryScope { - case .all: - return "완료되지 않은 일이 생기면 이곳에서 우선순위대로 볼 수 있습니다." - case .focused: - return "중요 표시한 Todo가 생기면 이곳에서 바로 볼 수 있습니다." - case .overdue: - return "지금은 기한이 지난 Todo가 없습니다." - case .dueSoon: - return "곧 마감되는 Todo가 생기면 이곳에서 먼저 볼 수 있습니다." - } + private enum Path: Hashable { + case detail(String) } }