diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 7d2a9641..f4e8c0d1 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -12,13 +12,12 @@ struct TodoRequest: Encodable { let isPinned: Bool let isCompleted: Bool let isChecked: Bool - let isDeleted: Bool - let number: Int? let title: String let content: String let createdAt: Date let updatedAt: Date let completedAt: Date? + let deletedAt: Date? let dueDate: Date? let tags: [String] let category: String @@ -35,6 +34,7 @@ struct TodoResponse { let createdAt: Date let updatedAt: Date let completedAt: Date? + let deletedAt: Date? let dueDate: Date? let tags: [String] let category: TodoCategoryResponse diff --git a/DevLog/Data/DTO/UserProfileResponse.swift b/DevLog/Data/DTO/UserProfileResponse.swift index 0983db6d..70dc587e 100644 --- a/DevLog/Data/DTO/UserProfileResponse.swift +++ b/DevLog/Data/DTO/UserProfileResponse.swift @@ -12,4 +12,5 @@ struct UserProfileResponse: Decodable { let email: String let statusMessage: String let avatarURL: URL? + let createdAt: Date } diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index 15b55d52..73a43448 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -12,13 +12,12 @@ extension TodoRequest { isPinned: entity.isPinned, isCompleted: entity.isCompleted, isChecked: entity.isChecked, - isDeleted: false, - number: entity.number, title: entity.title, content: entity.content, createdAt: entity.createdAt, updatedAt: entity.updatedAt, completedAt: entity.completedAt, + deletedAt: entity.deletedAt, dueDate: entity.dueDate, tags: entity.tags, category: entity.category.storageValue @@ -48,6 +47,7 @@ extension TodoResponse { createdAt: self.createdAt, updatedAt: self.updatedAt, completedAt: self.completedAt, + deletedAt: self.deletedAt, dueDate: self.dueDate, tags: self.tags, category: todoCategory diff --git a/DevLog/Data/Mapper/UserProfileMapping.swift b/DevLog/Data/Mapper/UserProfileMapping.swift index b8259bf7..fbaed419 100644 --- a/DevLog/Data/Mapper/UserProfileMapping.swift +++ b/DevLog/Data/Mapper/UserProfileMapping.swift @@ -11,7 +11,8 @@ extension UserProfileResponse { name: self.name, email: self.email, statusMessage: self.statusMessage, - avatarURL: self.avatarURL + avatarURL: self.avatarURL, + createdAt: self.createdAt ) } } diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index f53affdb..ec3a49d3 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -135,6 +135,7 @@ private extension TodoRepositoryImpl { createdAt: response.createdAt, updatedAt: response.updatedAt, completedAt: response.completedAt, + deletedAt: response.deletedAt, dueDate: response.dueDate, tags: response.tags, category: .decoded(category) diff --git a/DevLog/Domain/Entity/ActivityKind.swift b/DevLog/Domain/Entity/ActivityKind.swift new file mode 100644 index 00000000..2f15258f --- /dev/null +++ b/DevLog/Domain/Entity/ActivityKind.swift @@ -0,0 +1,14 @@ +// +// ActivityKind.swift +// DevLog +// +// Created by opfic on 4/4/26. +// + +import Foundation + +enum ActivityKind: String, Hashable { + case created + case completed + case deleted +} diff --git a/DevLog/Domain/Entity/Todo.swift b/DevLog/Domain/Entity/Todo.swift index da3c69e9..5de27141 100644 --- a/DevLog/Domain/Entity/Todo.swift +++ b/DevLog/Domain/Entity/Todo.swift @@ -18,6 +18,7 @@ struct Todo: Hashable { var createdAt: Date // 할 일 생성 날짜 var updatedAt: Date // 할 일 수정 날짜 var completedAt: Date? // 할 일 완료 날짜 + var deletedAt: Date? // 할 일 삭제 날짜 var dueDate: Date? // 할 일의 마감 날짜 (선택 사항) var tags: [String] // 할 일에 연결된 태그들 var category: TodoCategory // 할 일의 종류 diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index 6ce1f40a..53893c3c 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -10,6 +10,8 @@ import Foundation struct TodoQuery: Equatable { enum SortTarget: Equatable, Hashable { case createdAt + case completedAt + case deletedAt case updatedAt case dueDate @@ -17,6 +19,10 @@ struct TodoQuery: Equatable { switch self { case .createdAt: return "createdAt" + case .completedAt: + return "completedAt" + case .deletedAt: + return "deletedAt" case .updatedAt: return "updatedAt" case .dueDate: @@ -62,8 +68,9 @@ struct TodoQuery: Equatable { var isPinned: Bool? var completionFilter: CompletionFilter var dueDateFilter: DueDateFilter - var createdAtFrom: Date? - var createdAtTo: Date? + var sortDateFrom: Date? + var sortDateTo: Date? + var includesDeleted: Bool var sortTarget: SortTarget var sortOrder: SortOrder var pageSize: Int @@ -75,8 +82,9 @@ struct TodoQuery: Equatable { isPinned: Bool? = nil, completionFilter: CompletionFilter = .all, dueDateFilter: DueDateFilter = .all, - createdAtFrom: Date? = nil, - createdAtTo: Date? = nil, + sortDateFrom: Date? = nil, + sortDateTo: Date? = nil, + includesDeleted: Bool = false, sortTarget: SortTarget = .createdAt, sortOrder: SortOrder = .latest, pageSize: Int = 20, @@ -87,8 +95,9 @@ struct TodoQuery: Equatable { self.isPinned = isPinned self.completionFilter = completionFilter self.dueDateFilter = dueDateFilter - self.createdAtFrom = createdAtFrom - self.createdAtTo = createdAtTo + self.sortDateFrom = sortDateFrom + self.sortDateTo = sortDateTo + self.includesDeleted = includesDeleted self.sortTarget = sortTarget self.sortOrder = sortOrder self.pageSize = pageSize diff --git a/DevLog/Domain/Entity/UserProfile.swift b/DevLog/Domain/Entity/UserProfile.swift index 3a90e421..c4b13635 100644 --- a/DevLog/Domain/Entity/UserProfile.swift +++ b/DevLog/Domain/Entity/UserProfile.swift @@ -12,4 +12,5 @@ struct UserProfile { let email: String let statusMessage: String let avatarURL: URL? + let createdAt: Date } diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 20736dda..f50b562e 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -36,8 +36,9 @@ final class TodoService { 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, + query.sortDateFrom != nil ? "sortDateFrom=\(query.sortDateFrom!)" : nil, + query.sortDateTo != nil ? "sortDateTo=\(query.sortDateTo!)" : nil, + query.includesDeleted ? "includesDeleted=true" : nil, "pageSize=\(query.pageSize)", query.fetchAllPages ? "fetchAllPages=true" : nil, cursor != nil ? "cursor=\(cursor!)" : nil @@ -73,17 +74,17 @@ final class TodoService { firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) } - if let createdAtFrom = query.createdAtFrom { + if let sortDateFrom = query.sortDateFrom { firestoreQuery = firestoreQuery.whereField( - "createdAt", - isGreaterThanOrEqualTo: Timestamp(date: createdAtFrom) + query.sortTarget.fieldName, + isGreaterThanOrEqualTo: Timestamp(date: sortDateFrom) ) } - if let createdAtTo = query.createdAtTo { + if let sortDateTo = query.sortDateTo { firestoreQuery = firestoreQuery.whereField( - "createdAt", - isLessThan: Timestamp(date: createdAtTo) + query.sortTarget.fieldName, + isLessThan: Timestamp(date: sortDateTo) ) } @@ -165,12 +166,12 @@ final class TodoService { let docRef = collection.document(request.id) var data = try encoder.encode(request) data.removeValue(forKey: TodoFieldKey.id.rawValue) - if let number = request.number { - data[TodoFieldKey.number.rawValue] = number - } if request.completedAt == nil { data[TodoFieldKey.completedAt.rawValue] = NSNull() } + if request.deletedAt == nil { + data[TodoFieldKey.deletedAt.rawValue] = NSNull() + } if request.dueDate == nil { data[TodoFieldKey.dueDate.rawValue] = NSNull() } @@ -229,7 +230,7 @@ final class TodoService { do { let snapshot = try await store.collection(FirestorePath.todos(uid)) .whereField(FieldPath.documentID(), isEqualTo: todoId) - .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) + .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) .limit(to: 1) .getDocuments() guard let document = snapshot.documents.first, let todo = makeResponse(from: document) else { @@ -256,7 +257,7 @@ final class TodoService { group.addTask { let snapshot = try await collection .whereField(TodoFieldKey.number.rawValue, in: chunk) - .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) + .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) .getDocuments() return snapshot.documents } @@ -272,8 +273,7 @@ final class TodoService { return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { partialResult, document in let data = document.data() guard - !(data[TodoFieldKey.deletingAt.rawValue] is Timestamp), - (data[TodoFieldKey.isDeleted.rawValue] as? Bool) != true, + data[TodoFieldKey.deletedAt.rawValue] is NSNull, let response = makeResponse(from: document) else { return @@ -359,8 +359,11 @@ private extension TodoService { } func makeQuery(uid: String, query: TodoQuery) -> Query { - let collection = store.collection(FirestorePath.todos(uid)) - .whereField(TodoFieldKey.isDeleted.rawValue, isEqualTo: false) + var collection: Query = store.collection(FirestorePath.todos(uid)) + + if !query.includesDeleted { + collection = collection.whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) + } switch query.sortTarget { case .dueDate: @@ -368,7 +371,7 @@ private extension TodoService { .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) .order(by: "updatedAt", descending: true) .order(by: FieldPath.documentID()) - case .createdAt, .updatedAt: + case .createdAt, .completedAt, .deletedAt, .updatedAt: return collection .order(by: query.sortTarget.fieldName, descending: query.sortOrder.isDescending) .order(by: FieldPath.documentID()) @@ -389,7 +392,7 @@ private extension TodoService { Timestamp(date: sortDate), cursor.documentID ] - case .createdAt, .updatedAt: + case .createdAt, .completedAt, .deletedAt, .updatedAt: return [ primaryValue, cursor.documentID @@ -420,7 +423,7 @@ private extension TodoService { return nil } secondarySortDate = updatedAt.dateValue() - case .createdAt, .updatedAt: + case .createdAt, .completedAt, .deletedAt, .updatedAt: secondarySortDate = nil } @@ -432,10 +435,6 @@ private extension TodoService { } func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { - if snapshot.data()[TodoFieldKey.deletingAt.rawValue] is Timestamp || - (snapshot.data()[TodoFieldKey.isDeleted.rawValue] as? Bool) == true { - return nil - } return makeResponse(documentID: snapshot.documentID, data: snapshot.data()) } @@ -447,26 +446,25 @@ private extension TodoService { } func makeResponse(documentID: String, data: [String: Any]) -> TodoResponse? { - if data[TodoFieldKey.deletingAt.rawValue] is Timestamp || - (data[TodoFieldKey.isDeleted.rawValue] as? Bool) == true { - return nil - } guard - let isPinned = data[TodoFieldKey.isPinned.rawValue] as? Bool, - let isCompleted = data[TodoFieldKey.isCompleted.rawValue] as? Bool, - let isChecked = data[TodoFieldKey.isChecked.rawValue] as? Bool, let number = data[TodoFieldKey.number.rawValue] as? Int, let title = data[TodoFieldKey.title.rawValue] as? String, - let content = data[TodoFieldKey.content.rawValue] as? String, let createdAtTimestamp = data[TodoFieldKey.createdAt.rawValue] as? Timestamp, let updatedAtTimestamp = data[TodoFieldKey.updatedAt.rawValue] as? Timestamp, - let tags = data[TodoFieldKey.tags.rawValue] as? [String], let category = data[TodoFieldKey.category.rawValue] as? String else { return nil } let completedAt = (data[TodoFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() + let deletedAt = (data[TodoFieldKey.deletedAt.rawValue] as? Timestamp)?.dateValue() let dueDate = (data[TodoFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue() + + let isPinned = data[TodoFieldKey.isPinned.rawValue] as? Bool ?? false + let isCompleted = data[TodoFieldKey.isCompleted.rawValue] as? Bool ?? (completedAt != nil) + let isChecked = data[TodoFieldKey.isChecked.rawValue] as? Bool ?? false + let content = data[TodoFieldKey.content.rawValue] as? String ?? "" + let tags = data[TodoFieldKey.tags.rawValue] as? [String] ?? [] + return TodoResponse( id: documentID, isPinned: isPinned, @@ -478,6 +476,7 @@ private extension TodoService { createdAt: createdAtTimestamp.dateValue(), updatedAt: updatedAtTimestamp.dateValue(), completedAt: completedAt, + deletedAt: deletedAt, dueDate: dueDate, tags: tags, category: .raw(category) @@ -495,11 +494,10 @@ private extension TodoService { case createdAt case updatedAt case completedAt + case deletedAt case dueDate case tags case category - case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 - case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } enum CounterFieldKey: String { diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index 3c8c4a8f..829ef554 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -51,6 +51,7 @@ final class UserService { let userDocument = try await userRef.getDocument() if !userDocument.exists { userField["statusMsg"] = "" + userField["createdAt"] = FieldValue.serverTimestamp() } var settingField = ["fcmToken": response.fcmToken] @@ -117,11 +118,14 @@ final class UserService { do { let infoRef = store.document(FirestorePath.userData(uid, document: .info)) let data = try await infoRef.getDocument().data() + let createdAt = (data?["createdAt"] as? Timestamp)?.dateValue() + ?? Auth.auth().currentUser?.metadata.creationDate guard let provider = data?["currentProvider"] as? String, let name = data?[provider == "apple.com" ? "appleName" : "name"] as? String, let email = data?["email"] as? String, - let statusMessage = data?["statusMsg"] as? String + let statusMessage = data?["statusMsg"] as? String, + let createdAt else { logger.error("User profile data not found") throw FirestoreError.dataNotFound("User Profile") @@ -132,7 +136,8 @@ final class UserService { name: name, email: email, statusMessage: statusMessage, - avatarURL: Auth.auth().currentUser?.photoURL + avatarURL: Auth.auth().currentUser?.photoURL, + createdAt: createdAt ) } catch { logger.error("Failed to fetch user profile", error: error) @@ -159,15 +164,15 @@ final class UserService { } func updateFCMToken(_ fcmToken: String) async throws { - guard let userId = Auth.auth().currentUser?.uid else { + guard let uid = Auth.auth().currentUser?.uid else { logger.info("Skipping FCM token update because no authenticated user exists") return } - logger.info("Updating FCM token for user: \(userId)") - + logger.info("Updating FCM token for user: \(uid)") + do { - let tokensRef = store.document(FirestorePath.userData(userId, document: .tokens)) + let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) try await tokensRef.setData(["fcmToken": fcmToken], merge: true) logger.info("Successfully updated FCM token") } catch { @@ -177,15 +182,15 @@ final class UserService { } func updateUserTimeZone() async throws { - guard let userId = Auth.auth().currentUser?.uid else { + guard let uid = Auth.auth().currentUser?.uid else { logger.info("Skipping timeZone update because no authenticated user exists") return } - logger.info("Updating timeZone for user: \(userId)") + logger.info("Updating timeZone for user: \(uid)") do { - let settingsRef = store.document(FirestorePath.userData(userId, document: .settings)) + let settingsRef = store.document(FirestorePath.userData(uid, document: .settings)) try await settingsRef.setData( ["timeZone": TimeZone.autoupdatingCurrent.identifier], merge: true diff --git a/DevLog/Presentation/Structure/Profile/ActivityKindItem.swift b/DevLog/Presentation/Structure/Profile/ActivityKindItem.swift new file mode 100644 index 00000000..218b8c2d --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ActivityKindItem.swift @@ -0,0 +1,46 @@ +// +// ActivityKindItem.swift +// DevLog +// +// Created by opfic on 4/4/26. +// + +import SwiftUI + +struct ActivityKindItem: Identifiable, Hashable { + private let activityKind: ActivityKind + + init(from activityKind: ActivityKind) { + self.activityKind = activityKind + } + + static var selectableItems: [ActivityKindItem] {[ + .init(from: .created), .init(from: .completed), .init(from: .deleted) ] + } + + var id: String { activityKind.rawValue } + + var rawValue: String { activityKind.rawValue } + + var title: String { + switch activityKind { + case .created: + return String(localized: "profile_activity_created") + case .completed: + return String(localized: "profile_activity_completed") + case .deleted: + return String(localized: "profile_activity_deleted") + } + } + + var badgeColor: Color { + switch activityKind { + case .created: + return .orange + case .completed: + return .blue + case .deleted: + return .red + } + } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityDay.swift similarity index 64% rename from DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift rename to DevLog/Presentation/Structure/Profile/ProfileActivityDay.swift index f33f4b87..c386d307 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityDay.swift @@ -1,5 +1,5 @@ // -// ProfileCompletionDay.swift +// ProfileActivityDay.swift // DevLog // // Created by opfic on 3/2/26. @@ -7,9 +7,10 @@ import Foundation -struct ProfileCompletionDay: Hashable { +struct ProfileActivityDay: Hashable { let date: Date let createdCount: Int let completedCount: Int + let deletedCount: Int let isVisible: Bool } diff --git a/DevLog/Presentation/Structure/Profile/ProfileActivityItem.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityItem.swift new file mode 100644 index 00000000..01414f76 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityItem.swift @@ -0,0 +1,42 @@ +// +// ProfileActivityItem.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileActivityItem: Identifiable, Hashable, Comparable { + var id: String { todoId } + let todoId: String + let title: String + let number: Int + let category: TodoCategory + let activityKinds: [ActivityKind] + let isDeleted: Bool + + var activityKindItems: [ActivityKindItem] { + let orderedKinds: [ActivityKind] = [.created, .completed, .deleted] + return orderedKinds.compactMap { activityKind in + if activityKinds.contains(activityKind) { + return ActivityKindItem(from: activityKind) + } + return nil + } + } + + init?(todo: Todo, activityKinds: [ActivityKind]) { + guard let number = todo.number else { return nil } + self.todoId = todo.id + self.title = todo.title + self.number = number + self.category = todo.category + self.activityKinds = activityKinds + self.isDeleted = todo.deletedAt != nil + } + + static func < (lhs: ProfileActivityItem, rhs: ProfileActivityItem) -> Bool { + lhs.number < rhs.number + } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityMonth.swift similarity index 50% rename from DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift rename to DevLog/Presentation/Structure/Profile/ProfileActivityMonth.swift index a5675403..c7215c84 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityMonth.swift @@ -1,5 +1,5 @@ // -// ProfileCompletionMonth.swift +// ProfileActivityMonth.swift // DevLog // // Created by opfic on 3/2/26. @@ -7,8 +7,8 @@ import Foundation -struct ProfileCompletionMonth: Identifiable, Hashable { +struct ProfileActivityMonth: Identifiable, Hashable { var id: Date { monthStart } let monthStart: Date - let weeks: [[ProfileCompletionDay]] + let weeks: [[ProfileActivityDay]] } diff --git a/DevLog/Presentation/Structure/Profile/ProfileActivityQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityQuarter.swift new file mode 100644 index 00000000..7377e3d9 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityQuarter.swift @@ -0,0 +1,14 @@ +// +// ProfileActivityQuarter.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileActivityQuarter: Identifiable, Hashable { + var id: Date { quarterStart } + let quarterStart: Date + let months: [ProfileActivityMonth] +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift deleted file mode 100644 index 5ffaefe9..00000000 --- a/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ProfileActivityType.swift -// DevLog -// -// Created by opfic on 3/2/26. -// - -import Foundation - -enum ProfileActivityType: String, CaseIterable, Hashable { - case created - case completed - - var title: String { - switch self { - case .created: return String(localized: "profile_activity_created") - case .completed: return String(localized: "profile_activity_completed") - } - } -} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift deleted file mode 100644 index 67f3db61..00000000 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// ProfileCompletionQuarter.swift -// DevLog -// -// Created by opfic on 3/2/26. -// - -import Foundation - -struct ProfileCompletionQuarter: Identifiable, Hashable { - var id: Date { quarterStart } - let quarterStart: Date - let months: [ProfileCompletionMonth] - - var weeklyTrendPoints: [ProfileWeeklyTrendPoint] { - Self.makeWeeklyTrendPoints(from: months, calendar: .current) - } - - var maxCount: Int { - months - .flatMap { $0.weeks } - .flatMap { $0 } - .filter { $0.isVisible } - .map { $0.createdCount + $0.completedCount } - .max() ?? 0 - } - - static func makeWeeklyTrendPoints( - from months: [ProfileCompletionMonth], - calendar: Calendar - ) -> [ProfileWeeklyTrendPoint] { - let days = months - .flatMap(\.weeks) - .flatMap { $0 } - .filter(\.isVisible) - let groupedByWeekStart = Dictionary(grouping: days) { day in - calendar.dateInterval(of: .weekOfYear, for: day.date)?.start - ?? calendar.startOfDay(for: day.date) - } - - return groupedByWeekStart.keys.sorted().enumerated().map { index, weekStart in - let weekDays = groupedByWeekStart[weekStart, default: []] - return ProfileWeeklyTrendPoint( - weekStart: weekStart, - weekIndex: index + 1, - createdCount: weekDays.reduce(0) { $0 + $1.createdCount }, - completedCount: weekDays.reduce(0) { $0 + $1.completedCount } - ) - } - } -} diff --git a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift deleted file mode 100644 index 8cf1dfe9..00000000 --- a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ProfileSelectedDayActivity.swift -// DevLog -// -// Created by opfic on 3/2/26. -// - -import Foundation - -struct ProfileSelectedDayActivity: Identifiable, Hashable { - let todo: Todo - let showsCreated: Bool - let showsCompleted: Bool - - var id: String { todo.id } - - var activityLabel: String { - if showsCreated && showsCompleted { - return String(localized: "profile_activity_created_completed") - } - return showsCreated - ? String(localized: "profile_activity_created") - : String(localized: "profile_activity_completed") - } -} diff --git a/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift b/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift deleted file mode 100644 index 2df0be55..00000000 --- a/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// ProfileWeeklyTrendPoint.swift -// DevLog -// -// Created by opfic on 3/6/26. -// - -import Foundation - -struct ProfileWeeklyTrendPoint: Identifiable, Hashable { - var id: Date { weekStart } - let weekStart: Date - let weekIndex: Int - let createdCount: Int - let completedCount: Int - - func count(for activityType: ProfileActivityType) -> Int { - switch activityType { - case .created: - createdCount - case .completed: - completedCount - } - } -} diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/Todo/RecentTodoItem.swift similarity index 78% rename from DevLog/Presentation/Structure/RecentTodoItem.swift rename to DevLog/Presentation/Structure/Todo/RecentTodoItem.swift index 57ac2cdc..20adae25 100644 --- a/DevLog/Presentation/Structure/RecentTodoItem.swift +++ b/DevLog/Presentation/Structure/Todo/RecentTodoItem.swift @@ -9,16 +9,17 @@ import Foundation struct RecentTodoItem: Identifiable, Hashable { let id: String - let number: Int? + let number: Int let title: String let isPinned: Bool let updatedAt: Date let tags: [String] var category: TodoCategory - init(from todo: Todo) { + init?(from todo: Todo) { + guard let number = todo.number else { return nil } self.id = todo.id - self.number = todo.number + self.number = number self.title = todo.title self.isPinned = todo.isPinned self.updatedAt = todo.updatedAt diff --git a/DevLog/Presentation/Structure/SystemTodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift similarity index 100% rename from DevLog/Presentation/Structure/SystemTodoCategoryItem.swift rename to DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift diff --git a/DevLog/Presentation/Structure/TodayTodoItem.swift b/DevLog/Presentation/Structure/Todo/TodayTodoItem.swift similarity index 80% rename from DevLog/Presentation/Structure/TodayTodoItem.swift rename to DevLog/Presentation/Structure/Todo/TodayTodoItem.swift index 7afd16cc..0ee57bbf 100644 --- a/DevLog/Presentation/Structure/TodayTodoItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodayTodoItem.swift @@ -9,7 +9,7 @@ import Foundation struct TodayTodoItem: Identifiable, Hashable { let id: String - let number: Int? + let number: Int let title: String let tags: [String] let isPinned: Bool @@ -17,9 +17,10 @@ struct TodayTodoItem: Identifiable, Hashable { let dueDate: Date? let category: TodoCategory - init(from todo: Todo) { + init?(from todo: Todo) { + guard let number = todo.number else { return nil } self.id = todo.id - self.number = todo.number + self.number = number self.title = todo.title self.tags = todo.tags self.isPinned = todo.isPinned diff --git a/DevLog/Presentation/Structure/TodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift similarity index 100% rename from DevLog/Presentation/Structure/TodoCategoryItem.swift rename to DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift diff --git a/DevLog/Presentation/Structure/TodoIDItem.swift b/DevLog/Presentation/Structure/Todo/TodoIDItem.swift similarity index 100% rename from DevLog/Presentation/Structure/TodoIDItem.swift rename to DevLog/Presentation/Structure/Todo/TodoIDItem.swift diff --git a/DevLog/Presentation/Structure/TodoListItem.swift b/DevLog/Presentation/Structure/Todo/TodoListItem.swift similarity index 80% rename from DevLog/Presentation/Structure/TodoListItem.swift rename to DevLog/Presentation/Structure/Todo/TodoListItem.swift index 01c7fdd0..ee8717d1 100644 --- a/DevLog/Presentation/Structure/TodoListItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodoListItem.swift @@ -9,7 +9,7 @@ import Foundation struct TodoListItem: Identifiable, Hashable { let id: String - let number: Int? + let number: Int let title: String let tags: [String] let isPinned: Bool @@ -17,9 +17,10 @@ struct TodoListItem: Identifiable, Hashable { let createdAt: Date let updatedAt: Date - init(from todo: Todo) { + init?(from todo: Todo) { + guard let number = todo.number else { return nil } self.id = todo.id - self.number = todo.number + self.number = number self.title = todo.title self.tags = todo.tags self.isPinned = todo.isPinned diff --git a/DevLog/Presentation/Structure/TodoReferenceItem.swift b/DevLog/Presentation/Structure/Todo/TodoReferenceItem.swift similarity index 100% rename from DevLog/Presentation/Structure/TodoReferenceItem.swift rename to DevLog/Presentation/Structure/Todo/TodoReferenceItem.swift diff --git a/DevLog/Presentation/Structure/UserTodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/UserTodoCategoryItem.swift similarity index 100% rename from DevLog/Presentation/Structure/UserTodoCategoryItem.swift rename to DevLog/Presentation/Structure/Todo/UserTodoCategoryItem.swift diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 36c3a7e9..efc26925 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -185,7 +185,7 @@ final class HomeViewModel: Store { let items = page.items .filter { $0.createdAt != $0.updatedAt } .prefix(5) - .map { RecentTodoItem(from: $0) } + .compactMap { RecentTodoItem(from: $0) } send(.updateRecentTodos(items)) } catch { send(.setAlert(isPresented: true, type: .error)) @@ -200,7 +200,7 @@ final class HomeViewModel: Store { let items = page.items .filter { $0.createdAt != $0.updatedAt } .prefix(5) - .map { RecentTodoItem(from: $0) } + .compactMap { RecentTodoItem(from: $0) } send(.updateRecentTodos(items)) } catch { send(.setAlert(isPresented: true, type: .error)) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 143d3937..3d95a557 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -14,17 +14,17 @@ final class ProfileViewModel: Store { var name: String = "" var email: String = "" var isNetworkConnected: Bool = true + var isLoading: Bool = false var statusMessage: String = "" var avatarURL: URL? var earliestQuarterStart: Date? var selectedQuarterStart: Date? var showQuarterPicker: Bool = false var selectedQuarterPickerYear = Calendar.current.component(.year, from: Date()) - var completionQuarter: ProfileCompletionQuarter? - var dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] - var selectedActivityTypes: Set = [.created, .completed] - var selectedDay: ProfileCompletionDay? - var selectedActivityForSheet: ProfileSelectedDayActivity? + var activityQuarter: ProfileActivityQuarter? + var dayActivitiesByDate: [Date: [ProfileActivityItem]] = [:] + var selectedActivityKinds: Set = [.created, .completed, .deleted] + var selectedDay: ProfileActivityDay? var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" @@ -32,37 +32,35 @@ final class ProfileViewModel: Store { } enum Action { - case onAppear + case onAppear, refresh case networkStatusChanged(Bool) + case setLoading(Bool) case setAlert(Bool) case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) - case setCompletionQuarter( + case setActivityQuarter( quarterStart: Date, - quarter: ProfileCompletionQuarter, - dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] + quarter: ProfileActivityQuarter, + dayActivitiesByDate: [Date: [ProfileActivityItem]] ) - case setEarliestQuarterStart(Date) case setQuarterPickerPresented(Bool) case setQuarterPickerYear(Int) case openQuarterPicker case selectQuarter(Date) case moveToCurrentQuarter case moveQuarter(Int) - case toggleActivityType(ProfileActivityType) - case selectDay(ProfileCompletionDay?) - case setSelectedActivityForSheet(ProfileSelectedDayActivity?) + case toggleActivityKind(ActivityKind) + case selectDay(ProfileActivityDay?) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } enum SideEffect { case fetchUserData - case fetchEarliestQuarterStart - case fetchCompletionQuarter(Date) + case fetchActivityQuarter(Date) case updateStatusMessage(String) - case updateHeatmapActivityTypes(Set) + case updateHeatmapActivityKinds(Set) } private(set) var state = State() @@ -73,6 +71,7 @@ final class ProfileViewModel: Store { private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase private let calendar = Calendar.current + private let loadingState = LoadingState() private var cancellables = Set() init( @@ -97,26 +96,24 @@ final class ProfileViewModel: Store { var state = self.state var effects: [SideEffect] = [] switch action { - case .onAppear: + case .onAppear, .refresh: if state.selectedQuarterStart == nil { guard let quarterStart = quarterStart(for: Date()) else { break } state.selectedQuarterStart = quarterStart } effects = [.fetchUserData] - if state.earliestQuarterStart == nil { - state.earliestQuarterStart = state.selectedQuarterStart - effects.append(.fetchEarliestQuarterStart) - } let rawValues = fetchHeatmapActivityTypesUseCase.execute() - let settings = normalizeActivityTypes(rawValues) + let settings = normalizeActivityKinds(rawValues) if !settings.isEmpty { - state.selectedActivityTypes = settings + state.selectedActivityKinds = settings } if let selectedQuarterStart = state.selectedQuarterStart { - effects.append(.fetchCompletionQuarter(selectedQuarterStart)) + effects.append(.fetchActivityQuarter(selectedQuarterStart)) } case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected + case .setLoading(let isLoading): + state.isLoading = isLoading case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .tapResetStatusMessageButton: @@ -126,8 +123,10 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL - case .setEarliestQuarterStart(let quarterStart): - state.earliestQuarterStart = quarterStart + if state.earliestQuarterStart == nil { + state.earliestQuarterStart = quarterStart(for: profile.createdAt) + ?? calendar.startOfDay(for: profile.createdAt) + } case .setQuarterPickerPresented(let isPresented): state.showQuarterPicker = isPresented case .setQuarterPickerYear(let year): @@ -137,9 +136,9 @@ final class ProfileViewModel: Store { state.selectedQuarterPickerYear = calendar.component(.year, from: selectedQuarterStart) } state.showQuarterPicker = true - case .setCompletionQuarter(let quarterStart, let quarter, let dayActivitiesByDate): + case .setActivityQuarter(let quarterStart, let quarter, let dayActivitiesByDate): guard state.selectedQuarterStart == quarterStart else { break } - state.completionQuarter = quarter + state.activityQuarter = quarter state.dayActivitiesByDate = dayActivitiesByDate case .selectDay(let day): if let day, state.selectedDay?.date == day.date { @@ -147,8 +146,6 @@ final class ProfileViewModel: Store { } else { state.selectedDay = day } - case .setSelectedActivityForSheet(let activity): - state.selectedActivityForSheet = activity case .selectQuarter(let quarterStart): guard canSelectQuarter(quarterStart) else { break } state.showQuarterPicker = false @@ -167,17 +164,17 @@ final class ProfileViewModel: Store { ) else { break } guard canSelectQuarter(nextQuarterStart) else { break } updateSelectedQuarter(to: nextQuarterStart, state: &state, effects: &effects) - case .toggleActivityType(let activityType): - if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { + case .toggleActivityKind(let activityKind): + if state.selectedActivityKinds.contains(activityKind), state.selectedActivityKinds.count == 1 { break } - if state.selectedActivityTypes.contains(activityType) { - state.selectedActivityTypes.remove(activityType) + if state.selectedActivityKinds.contains(activityKind) { + state.selectedActivityKinds.remove(activityKind) } else { - state.selectedActivityTypes.insert(activityType) + state.selectedActivityKinds.insert(activityKind) } - effects = [.updateHeatmapActivityTypes(state.selectedActivityTypes)] + effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds)] case .willUpdateStatusMessage: if !state.isNetworkConnected { break } let message = self.state.statusMessage @@ -203,27 +200,17 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } - case .fetchEarliestQuarterStart: - Task { - do { - let earliestQuarterStart = try await fetchEarliestQuarterStart() - send(.setEarliestQuarterStart(earliestQuarterStart)) - } catch { - send(.setAlert(true)) - } - } - case .fetchCompletionQuarter(let quarterStart): + case .fetchActivityQuarter(let quarterStart): + beginLoading(mode: .immediate) Task { do { - let todos = try await fetchQuarterTodos(from: quarterStart) - let months = makeCompletionMonths(from: todos, quarterStart: quarterStart) - let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) - let dayActivitiesByDate = makeDayActivitiesByDate(from: todos) + defer { endLoading(mode: .immediate) } + let quarterActivityData = try await fetchQuarterActivityData(from: quarterStart) send( - .setCompletionQuarter( + .setActivityQuarter( quarterStart: quarterStart, - quarter: quarter, - dayActivitiesByDate: dayActivitiesByDate + quarter: quarterActivityData.quarter, + dayActivitiesByDate: quarterActivityData.dayActivitiesByDate ) ) } catch { @@ -238,15 +225,42 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } - case .updateHeatmapActivityTypes(let activityTypes): - let rawValues = ProfileActivityType.allCases - .filter { activityTypes.contains($0) } + case .updateHeatmapActivityKinds(let activityKinds): + let rawValues = ActivityKindItem.selectableItems .map(\.rawValue) + .filter { rawValue in + guard let activityKind = ActivityKind(rawValue: rawValue) else { + return false + } + return activityKinds.contains(activityKind) + } updateHeatmapActivityTypesUseCase.execute(rawValues) } } } +private struct ProfileActivityCounts { + var createdCount = 0 + var completedCount = 0 + var deletedCount = 0 + + mutating func increment(_ activityKind: ActivityKind) { + switch activityKind { + case .created: + createdCount += 1 + case .completed: + completedCount += 1 + case .deleted: + deletedCount += 1 + } + } +} + +private struct ProfileActivityEntry { + var todo: Todo + var activityKinds: Set +} + extension ProfileViewModel { private func setupNetworkObserving() { networkConnectivityUseCase.observe() @@ -269,14 +283,13 @@ extension ProfileViewModel { ) } - var selectedDayActivities: [ProfileSelectedDayActivity] { + var selectedDayActivities: [ProfileActivityItem] { guard let selectedDay = state.selectedDay else { return [] } let dayStart = calendar.startOfDay(for: selectedDay.date) let activities = state.dayActivitiesByDate[dayStart] ?? [] return activities.filter { activity in - (state.selectedActivityTypes.contains(.created) && activity.showsCreated) - || (state.selectedActivityTypes.contains(.completed) && activity.showsCompleted) + !Set(activity.activityKinds).isDisjoint(with: state.selectedActivityKinds) } } @@ -326,40 +339,10 @@ private extension ProfileViewModel { ) { guard state.selectedQuarterStart != quarterStart else { return } state.selectedQuarterStart = quarterStart - state.completionQuarter = nil + state.activityQuarter = nil state.dayActivitiesByDate = [:] state.selectedDay = nil - state.selectedActivityForSheet = nil - effects = [.fetchCompletionQuarter(quarterStart)] - } - - func makeDayActivitiesByDate(from todos: [Todo]) -> [Date: [ProfileSelectedDayActivity]] { - var activitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] - - for todo in todos { - let createdDay = calendar.startOfDay(for: todo.createdAt) - let completedDay = todo.completedAt.map { calendar.startOfDay(for: $0) } - - activitiesByDate[createdDay, default: []].append( - ProfileSelectedDayActivity( - todo: todo, - showsCreated: true, - showsCompleted: completedDay == createdDay - ) - ) - - if let completedDay, completedDay != createdDay { - activitiesByDate[completedDay, default: []].append( - ProfileSelectedDayActivity( - todo: todo, - showsCreated: false, - showsCompleted: true - ) - ) - } - } - - return activitiesByDate + effects = [.fetchActivityQuarter(quarterStart)] } func setAlert( @@ -371,34 +354,58 @@ private extension ProfileViewModel { state.showAlert = isPresented } - func fetchQuarterTodos(from quarterStart: Date) async throws -> [Todo] { + func fetchQuarterActivityData( + from quarterStart: Date + ) async throws -> (quarter: ProfileActivityQuarter, dayActivitiesByDate: [Date: [ProfileActivityItem]]) { guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return [] + return (ProfileActivityQuarter(quarterStart: quarterStart, months: []), [:]) } - let page = try await fetchTodosUseCase.execute( + async let createdTodoPage = fetchTodosUseCase.execute( TodoQuery( - createdAtFrom: quarterStart, - createdAtTo: nextQuarterStart, + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: .createdAt, pageSize: 100, fetchAllPages: true ), cursor: nil ) - return page.items - } - - func fetchEarliestQuarterStart() async throws -> Date { - let page = try await fetchTodosUseCase.execute( + async let completedTodoPage = fetchTodosUseCase.execute( TodoQuery( - sortTarget: .createdAt, - sortOrder: .oldest, - pageSize: 1 + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: .completedAt, + pageSize: 100, + fetchAllPages: true ), cursor: nil ) - let baseDate = page.items.first?.createdAt ?? Date() - return quarterStart(for: baseDate) ?? calendar.startOfDay(for: baseDate) + async let deletedTodoPage = fetchTodosUseCase.execute( + TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: .deletedAt, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + + let (createdTodoPageResult, completedTodoPageResult, deletedTodoPageResult) = try await ( + createdTodoPage, + completedTodoPage, + deletedTodoPage + ) + return makeQuarterActivityData( + createdTodos: createdTodoPageResult.items, + completedTodos: completedTodoPageResult.items, + deletedTodos: deletedTodoPageResult.items, + quarterStart: quarterStart + ) } func canSelectQuarter(_ quarterStart: Date) -> Bool { @@ -407,63 +414,60 @@ private extension ProfileViewModel { return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart } - func normalizeActivityTypes(_ rawValues: [String]) -> Set { - Set(rawValues.compactMap(ProfileActivityType.init(rawValue:))) - } - - func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [ProfileCompletionMonth] { - var dailyCreatedCount: [Date: Int] = [:] - var dailyCompletedCount: [Date: Int] = [:] - - for todo in todos { - let createdDay = calendar.startOfDay(for: todo.createdAt) - dailyCreatedCount[createdDay, default: 0] += 1 + func normalizeActivityKinds(_ rawValues: [String]) -> Set { + let selectableActivityKindRawValues = Set(ActivityKindItem.selectableItems.map(\.rawValue)) - if let completedAt = todo.completedAt { - let completedDay = calendar.startOfDay(for: completedAt) - dailyCompletedCount[completedDay, default: 0] += 1 - } - } + return Set( + rawValues + .compactMap(ActivityKind.init(rawValue:)) + .filter { selectableActivityKindRawValues.contains($0.rawValue) } + ) + } + func makeActivityMonths( + dailyCountsByDate: [Date: ProfileActivityCounts], + quarterStart: Date + ) -> [ProfileActivityMonth] { let monthStarts = (0..<3).compactMap { calendar.date(byAdding: .month, value: $0, to: quarterStart) } return monthStarts.map { monthStart in - makeCompletionMonth( + makeActivityMonth( monthStart: monthStart, - createdCounts: dailyCreatedCount, - completedCounts: dailyCompletedCount, + dailyCountsByDate: dailyCountsByDate, calendar: calendar ) } } - func makeCompletionMonth( + func makeActivityMonth( monthStart: Date, - createdCounts: [Date: Int], - completedCounts: [Date: Int], + dailyCountsByDate: [Date: ProfileActivityCounts], calendar: Calendar - ) -> ProfileCompletionMonth { + ) -> ProfileActivityMonth { guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { - return ProfileCompletionMonth(monthStart: monthStart, weeks: []) + return ProfileActivityMonth(monthStart: monthStart, weeks: []) } - var days: [ProfileCompletionDay] = [] + var days: [ProfileActivityDay] = [] var cursor = firstWeekInterval.start while cursor < lastWeekInterval.end { let normalizedDate = calendar.startOfDay(for: cursor) let isInMonth = calendar.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month) - let createdCount = isInMonth ? (createdCounts[normalizedDate] ?? 0) : 0 - let completedCount = isInMonth ? (completedCounts[normalizedDate] ?? 0) : 0 + let dailyCounts = dailyCountsByDate[normalizedDate] ?? ProfileActivityCounts() + let createdCount = isInMonth ? dailyCounts.createdCount : 0 + let completedCount = isInMonth ? dailyCounts.completedCount : 0 + let deletedCount = isInMonth ? dailyCounts.deletedCount : 0 days.append( - ProfileCompletionDay( + ProfileActivityDay( date: normalizedDate, createdCount: createdCount, completedCount: completedCount, + deletedCount: deletedCount, isVisible: isInMonth ) ) @@ -471,7 +475,7 @@ private extension ProfileViewModel { cursor = nextDay } - var weeks: [[ProfileCompletionDay]] = [] + var weeks: [[ProfileActivityDay]] = [] var index = 0 while index < days.count { let endIndex = min(index + 7, days.count) @@ -479,7 +483,7 @@ private extension ProfileViewModel { index += 7 } - return ProfileCompletionMonth(monthStart: monthStart, weeks: weeks) + return ProfileActivityMonth(monthStart: monthStart, weeks: weeks) } func quarterStart(for date: Date) -> Date? { @@ -509,4 +513,98 @@ private extension ProfileViewModel { } return canSelectQuarter(targetQuarterStart) } + + func makeQuarterActivityData( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + quarterStart: Date + ) -> (quarter: ProfileActivityQuarter, dayActivitiesByDate: [Date: [ProfileActivityItem]]) { + var dailyCountsByDate: [Date: ProfileActivityCounts] = [:] + var activityEntriesByDate: [Date: [String: ProfileActivityEntry]] = [:] + + for todo in createdTodos { + appendProfileActivity( + todo: todo, + kind: .created, + occurredAt: todo.createdAt, + dailyCountsByDate: &dailyCountsByDate, + activityEntriesByDate: &activityEntriesByDate + ) + } + + for todo in completedTodos { + guard let completedAt = todo.completedAt else { continue } + appendProfileActivity( + todo: todo, + kind: .completed, + occurredAt: completedAt, + dailyCountsByDate: &dailyCountsByDate, + activityEntriesByDate: &activityEntriesByDate + ) + } + + for todo in deletedTodos { + guard let deletedAt = todo.deletedAt else { continue } + appendProfileActivity( + todo: todo, + kind: .deleted, + occurredAt: deletedAt, + dailyCountsByDate: &dailyCountsByDate, + activityEntriesByDate: &activityEntriesByDate + ) + } + + let quarter = ProfileActivityQuarter( + quarterStart: quarterStart, + months: makeActivityMonths(dailyCountsByDate: dailyCountsByDate, quarterStart: quarterStart) + ) + let dayActivitiesByDate = activityEntriesByDate.mapValues { activityEntries in + activityEntries.values.compactMap { activityEntry in + ProfileActivityItem( + todo: activityEntry.todo, + activityKinds: orderedActivityKinds(from: activityEntry.activityKinds) + ) + } + .sorted() + } + return (quarter, dayActivitiesByDate) + } + + func appendProfileActivity( + todo: Todo, + kind: ActivityKind, + occurredAt: Date, + dailyCountsByDate: inout [Date: ProfileActivityCounts], + activityEntriesByDate: inout [Date: [String: ProfileActivityEntry]] + ) { + let dayStart = calendar.startOfDay(for: occurredAt) + var profileActivityCounts = dailyCountsByDate[dayStart] ?? ProfileActivityCounts() + profileActivityCounts.increment(kind) + dailyCountsByDate[dayStart] = profileActivityCounts + + var activityEntries = activityEntriesByDate[dayStart] ?? [:] + var profileActivityEntry = activityEntries[todo.id] ?? ProfileActivityEntry(todo: todo, activityKinds: []) + profileActivityEntry.todo = todo + profileActivityEntry.activityKinds.insert(kind) + activityEntries[todo.id] = profileActivityEntry + activityEntriesByDate[dayStart] = activityEntries + } + + func orderedActivityKinds(from activityKinds: Set) -> [ActivityKind] { + let orderedActivityKinds: [ActivityKind] = [.created, .completed, .deleted] + return orderedActivityKinds.filter { activityKinds.contains($0) } + } + + func beginLoading(mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + func endLoading(mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 009b8785..0a8ab7a5 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -138,7 +138,7 @@ final class SearchViewModel: Store { defer { send(.setLoading(false)) } async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) async let webPages = fetchWebPagesUseCase.execute(query) - let todoItems = try await todos.items.map { TodoListItem(from: $0) } + let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } let webPageItems = try await webPages.map { WebPageItem(from: $0) } send(.fetchTodos(todoItems)) send(.fetchWebPage(webPageItems)) diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index d3d08641..d14bff96 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -215,8 +215,8 @@ final class TodayViewModel: Store { ), cursor: nil ) - let todosWithDueDate = try await todosWithDueDatePage.items.map { TodayTodoItem(from: $0) } - let todosWithoutDueDate = try await todosWithoutDueDatePage.items.map { TodayTodoItem(from: $0) } + let todosWithDueDate = try await todosWithDueDatePage.items.compactMap { TodayTodoItem(from: $0) } + let todosWithoutDueDate = try await todosWithoutDueDatePage.items.compactMap { TodayTodoItem(from: $0) } send(.fetchTodos(todosWithDueDate + todosWithoutDueDate)) } catch { send(.setAlert(true)) @@ -247,7 +247,11 @@ final class TodayViewModel: Store { todo.isPinned.toggle() todo.updatedAt = Date() try await upsertTodoUseCase.execute(todo) - send(.updateTodo(TodayTodoItem(from: todo))) + guard let todayTodoItem = TodayTodoItem(from: todo) else { + send(.setAlert(true)) + return + } + send(.updateTodo(todayTodoItem)) } catch { send(.setAlert(true)) } diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 73c40819..a3877ae3 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -100,6 +100,7 @@ final class TodoEditorViewModel: Store { private let isChecked: Bool private let number: Int? private let createdAt: Date? + private let deletedAt: Date? private let originalDraft: Draft? var navigationTitle: String { @@ -135,6 +136,7 @@ final class TodoEditorViewModel: Store { self.isChecked = false self.number = nil self.createdAt = nil + self.deletedAt = nil self.originalDraft = nil state.category = TodoCategoryItem(from: category) state.categories = [TodoCategoryItem(from: category)] @@ -153,6 +155,7 @@ final class TodoEditorViewModel: Store { self.isChecked = todo.isChecked self.number = todo.number self.createdAt = todo.createdAt + self.deletedAt = todo.deletedAt self.originalDraft = Draft(todo: todo) state.isCompleted = todo.isCompleted state.completedAt = todo.completedAt @@ -278,6 +281,7 @@ extension TodoEditorViewModel { createdAt: self.createdAt ?? date, updatedAt: date, completedAt: state.completedAt, + deletedAt: self.deletedAt, dueDate: state.dueDate, tags: state.tags.map { $0 }, category: state.category.category diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 59452c0a..50a7cd9e 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -148,7 +148,7 @@ final class TodoListViewModel: Store { defer { endLoading(.immediate) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nil) send(.resetPagination) - send(.appendTodos(page.items.map { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) + send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil send(.setHasMore(hasMore)) } catch { @@ -161,7 +161,7 @@ final class TodoListViewModel: Store { do { defer { endLoading(.immediate) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nextCursor) - send(.appendTodos(page.items.map { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) + send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil send(.setHasMore(hasMore)) } catch { @@ -175,7 +175,7 @@ final class TodoListViewModel: Store { defer { endLoading(.immediate) } let query = TodoQuery(category: state.category, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) - send(.fetchSearchResults(page.items.map { TodoListItem(from: $0) })) + send(.fetchSearchResults(page.items.compactMap { TodoListItem(from: $0) })) } catch { send(.setAlert(true)) } @@ -202,7 +202,11 @@ final class TodoListViewModel: Store { todo.completedAt = todo.isCompleted ? now : nil todo.updatedAt = now try await upsertTodoUseCase.execute(todo) - send(.didToggleCompleted(TodoListItem(from: todo))) + guard let todoListItem = TodoListItem(from: todo) else { + send(.setAlert(true)) + return + } + send(.didToggleCompleted(todoListItem)) } catch { send(.setAlert(true)) } @@ -216,7 +220,11 @@ final class TodoListViewModel: Store { todo.isPinned.toggle() todo.updatedAt = Date() try await upsertTodoUseCase.execute(todo) - send(.didTogglePinned(TodoListItem(from: todo))) + guard let todoListItem = TodoListItem(from: todo) else { + send(.setAlert(true)) + return + } + send(.didTogglePinned(todoListItem)) } catch { send(.setAlert(true)) } @@ -445,6 +453,10 @@ extension TodoQuery.SortTarget { switch self { case .createdAt: return String(localized: "todo_sort_created") + case .completedAt: + return String(localized: "profile_activity_completed") + case .deletedAt: + return String(localized: "profile_activity_deleted") case .updatedAt: return String(localized: "todo_sort_updated") case .dueDate: diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 67dd6dbd..5e533343 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -447,7 +447,7 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Saved web pages appear here." } }, @@ -775,23 +775,6 @@ } } }, - "profile_activity_axis" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activity" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "활동" - } - } - } - }, "profile_activity_completed" : { "extractionState" : "manual", "localizations" : { @@ -826,36 +809,19 @@ } } }, - "profile_activity_created_completed" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Created/Completed" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "생성/완료" - } - } - } - }, - "profile_activity_heatmap" : { + "profile_activity_deleted" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Activity Heatmap" + "value" : "Deleted" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "활동 히트맵" + "value" : "삭제" } } } @@ -894,23 +860,6 @@ } } }, - "profile_quarter_empty" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There is no selected activity in this quarter." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 분기에는 선택한 활동이 없어요" - } - } - } - }, "profile_quarter_format" : { "extractionState" : "manual", "localizations" : { @@ -928,23 +877,6 @@ } } }, - "profile_year_quarter_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Q%2$@ %1$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@년 %2$@분기" - } - } - } - }, "profile_quarterly_activity" : { "extractionState" : "manual", "localizations" : { @@ -996,23 +928,6 @@ } } }, - "profile_week_axis" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Week" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "주차" - } - } - } - }, "profile_weekday_fri" : { "extractionState" : "manual", "localizations" : { @@ -1064,36 +979,36 @@ } } }, - "profile_weekly_trend" : { + "profile_year" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Weekly Trend" + "value" : "Year" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "주간 추세" + "value" : "연도" } } } }, - "profile_year" : { + "profile_year_quarter_format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Year" + "value" : "Q%2$@ %1$@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연도" + "value" : "%1$@년 %2$@분기" } } } @@ -3452,4 +3367,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/DevLog/UI/Common/Component/TodoItemRow.swift b/DevLog/UI/Common/Component/TodoItemRow.swift index a341b9d8..7a878ca0 100644 --- a/DevLog/UI/Common/Component/TodoItemRow.swift +++ b/DevLog/UI/Common/Component/TodoItemRow.swift @@ -27,12 +27,10 @@ struct TodoItemRow: View { .font(.headline) .foregroundStyle(Color(.label)) .lineLimit(1) - if let number = item.number { - Text("#\(number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } + Text("#\(item.number)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.gray) + .fixedSize(horizontal: true, vertical: false) } HStack(spacing: 4) { if item.isPinned { diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift index df685a60..9c3a1562 100644 --- a/DevLog/UI/Common/TodoDetailContentView.swift +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -12,8 +12,7 @@ struct TodoDetailContentView: View { let title: String let content: String let referenceItems: [Int: TodoReferenceItem] - var number: Int? - var activityLabel: String? + var number: Int var onOpenTodoID: ((String) -> Void)? var body: some View { @@ -21,28 +20,11 @@ struct TodoDetailContentView: View { Color(.secondarySystemBackground).ignoresSafeArea() ScrollView { LazyVStack(alignment: .leading, spacing: 10) { - if let activityLabel { - HStack { - Text(activityLabel) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(Color(.systemGray4)) - ) - Spacer() - } - .padding(.horizontal) - } HStack(alignment: .firstTextBaseline, spacing: 8) { Text(title) - if let number { - Text("#\(number)") - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } + Text("#\(number)") + .foregroundStyle(.gray) + .fixedSize(horizontal: true, vertical: false) Spacer() } .font(.title3.bold()) diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 050834e7..f428413c 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -403,12 +403,10 @@ private struct RecentTodoRow: View { .foregroundStyle(Color.primary) .font(.headline) .lineLimit(1) - if let number = todo.number { - Text("#\(number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } + Text("#\(todo.number)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.gray) + .fixedSize(horizontal: true, vertical: false) } HStack(spacing: 6) { diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 786b3d16..b9e906d4 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -14,12 +14,12 @@ struct TodoDetailView: View { var body: some View { ZStack { Color(.secondarySystemBackground).ignoresSafeArea() - if let todo = viewModel.state.todo { + if let todo = viewModel.state.todo, let number = todo.number { TodoDetailContentView( title: todo.title, content: todo.content, referenceItems: viewModel.state.referenceItems, - number: todo.number, + number: number, onOpenTodoID: { viewModel.send(.setSelectedTodoId(TodoIdItem(id: $0))) } ) } else if viewModel.state.isLoading { diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index b6072093..6b862733 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -10,10 +10,10 @@ import SwiftUI struct ProfileHeatmapView: View { @Environment(\.safeAreaInsets) private var safeAreaInsets @Environment(\.sceneWidth) private var sceneWidth - let quarter: ProfileCompletionQuarter - let selectedActivityTypes: Set - let selectedDay: ProfileCompletionDay? - let onSelectDay: (ProfileCompletionDay) -> Void + let quarter: ProfileActivityQuarter + let selectedActivityKinds: Set + let selectedDay: ProfileActivityDay? + let onSelectDay: (ProfileActivityDay) -> Void var body: some View { let layout = ProfileHeatmapLayout( @@ -21,27 +21,22 @@ struct ProfileHeatmapView: View { weekCounts: quarter.months.map(\.weeks.count) ) - VStack(alignment: .leading, spacing: 10) { - Text(String(localized: "profile_activity_heatmap")) - .font(.subheadline) - .bold() - HStack(alignment: .top, spacing: 0) { - weekdayLabel(layout: layout) - HStack(alignment: .top, spacing: layout.monthSpacing) { - ForEach(quarter.months) { month in - MonthCompactHeatmapView( - month: month, - maxCount: quarter.maxCount, - layout: layout, - selectedActivityTypes: selectedActivityTypes, - selectedDay: selectedDay, - onSelectDay: onSelectDay - ) - } + HStack(alignment: .top, spacing: 0) { + weekdayLabel(layout: layout) + HStack(alignment: .top, spacing: layout.monthSpacing) { + ForEach(quarter.months) { month in + MonthCompactHeatmapView( + month: month, + maxCount: maxCount, + layout: layout, + selectedActivityKinds: selectedActivityKinds, + selectedDay: selectedDay, + onSelectDay: onSelectDay + ) } } - .padding(.vertical, 2) } + .padding(.vertical, 2) } @ViewBuilder @@ -108,6 +103,29 @@ struct ProfileHeatmapView: View { - (horizontalPadding * 2) ) } + + private var maxCount: Int { + quarter.months + .flatMap(\.weeks) + .flatMap { $0 } + .filter(\.isVisible) + .map(dayCount(for:)) + .max() ?? 0 + } + + private func dayCount(for day: ProfileActivityDay) -> Int { + var value = 0 + if selectedActivityKinds.contains(.created) { + value += day.createdCount + } + if selectedActivityKinds.contains(.completed) { + value += day.completedCount + } + if selectedActivityKinds.contains(.deleted) { + value += day.deletedCount + } + return value + } } private struct ProfileHeatmapLayout { @@ -142,12 +160,12 @@ private struct ProfileHeatmapLayout { private struct MonthCompactHeatmapView: View { @Environment(\.colorScheme) private var colorScheme - let month: ProfileCompletionMonth + let month: ProfileActivityMonth let maxCount: Int let layout: ProfileHeatmapLayout - let selectedActivityTypes: Set - let selectedDay: ProfileCompletionDay? - let onSelectDay: (ProfileCompletionDay) -> Void + let selectedActivityKinds: Set + let selectedDay: ProfileActivityDay? + let onSelectDay: (ProfileActivityDay) -> Void private let orderedWeekdays = Array(1...7) var body: some View { @@ -189,23 +207,23 @@ private struct MonthCompactHeatmapView: View { } } - private func isSelected(_ day: ProfileCompletionDay?) -> Bool { - guard let day, let selectedDay else { return false } + private func isSelected(_ day: ProfileActivityDay?) -> Bool { + guard let day, let selectedDay, day.isVisible else { return false } return Calendar.current.isDate(day.date, inSameDayAs: selectedDay.date) } - private func selectionInnerBorderColor(for day: ProfileCompletionDay?) -> Color { + private func selectionInnerBorderColor(for day: ProfileActivityDay?) -> Color { isSelected(day) ? .white : .clear } - private func selectionOuterBorderColor(for day: ProfileCompletionDay?) -> Color { + private func selectionOuterBorderColor(for day: ProfileActivityDay?) -> Color { if isSelected(day) && colorScheme == .light { return Color.gray } return .clear } - private func fillColor(for day: ProfileCompletionDay?, with maxCount: Int) -> Color { + private func fillColor(for day: ProfileActivityDay?, with maxCount: Int) -> Color { guard let day, day.isVisible else { return .clear } let count = dayCount(for: day) if count == 0 { @@ -214,14 +232,17 @@ private struct MonthCompactHeatmapView: View { return Color.blue.opacity(opacity(for: count, max: maxCount)) } - private func dayCount(for day: ProfileCompletionDay) -> Int { + private func dayCount(for day: ProfileActivityDay) -> Int { var value = 0 - if selectedActivityTypes.contains(.created) { + if selectedActivityKinds.contains(.created) { value += day.createdCount } - if selectedActivityTypes.contains(.completed) { + if selectedActivityKinds.contains(.completed) { value += day.completedCount } + if selectedActivityKinds.contains(.deleted) { + value += day.deletedCount + } return value } diff --git a/DevLog/UI/Profile/ProfileTrendChartView.swift b/DevLog/UI/Profile/ProfileTrendChartView.swift deleted file mode 100644 index 73517c77..00000000 --- a/DevLog/UI/Profile/ProfileTrendChartView.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// ProfileTrendChartView.swift -// DevLog -// -// Created by opfic on 3/6/26. -// - -import Charts -import SwiftUI - -struct ProfileTrendChartView: View { - let trendPoints: [ProfileWeeklyTrendPoint] - let selectedActivityTypes: Set - - private let chartHeight: CGFloat = 190 - private let createdColor = Color.orange - private let completedColor = Color.blue - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - Text(String(localized: "profile_weekly_trend")) - .font(.subheadline) - .bold() - - Spacer() - - HStack(spacing: 10) { - if selectedActivityTypes.contains(.created) { - legendItem(title: ProfileActivityType.created.title, color: createdColor) - } - if selectedActivityTypes.contains(.completed) { - legendItem(title: ProfileActivityType.completed.title, color: completedColor) - } - } - } - - if hasVisibleActivity { - Chart { - if selectedActivityTypes.contains(.created) { - createdSeries - } - - if selectedActivityTypes.contains(.completed) { - completedSeries - } - } - .chartLegend(.hidden) - .chartXScale( - domain: xDomain, - range: .plotDimension(startPadding: 4, endPadding: 14) - ) - .chartXAxis { - AxisMarks(values: axisWeekIndices) { value in - AxisValueLabel { - if let weekIndex = value.as(Int.self) { - Text("\(weekIndex)") - .font(.caption2) - .fixedSize() - } - } - if let weekIndex = value.as(Int.self), weekIndex != xDomain.upperBound { - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(.quaternary) - AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(.quaternary) - } - } - } - .chartYAxis { - AxisMarks(position: .leading, values: yAxisValues) { _ in - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(.quaternary) - AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(.quaternary) - AxisValueLabel() - } - } - .chartYScale(domain: yDomain) - .chartPlotStyle { plotArea in - plotArea - .background(Color(.systemGray6).opacity(0.6)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .frame(height: chartHeight) - - Text(String(localized: "profile_week_axis")) - .font(.caption2) - .foregroundStyle(.secondary) - } else { - VStack(spacing: 6) { - Image(systemName: "chart.line.uptrend.xyaxis") - .font(.title3) - .foregroundStyle(.secondary) - Text(String(localized: "profile_quarter_empty")) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: chartHeight) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6).opacity(0.6)) - ) - } - } - } - - private var axisWeekIndices: [Int] { - let weekIndices = trendPoints.map(\.weekIndex) - guard weekIndices.count > 6 else { return weekIndices } - - var labels = Set() - let lastIndex = weekIndices.count - 1 - - for (offset, weekIndex) in weekIndices.enumerated() { - if offset == 0 || offset == lastIndex || offset.isMultiple(of: 2) { - labels.insert(weekIndex) - } - } - - return labels.sorted() - } - - private var visibleCounts: [Int] { - trendPoints.flatMap { point in - ProfileActivityType.allCases.compactMap { activityType in - guard selectedActivityTypes.contains(activityType) else { return nil } - return point.count(for: activityType) - } - } - } - - private var maxVisibleCount: Int { - max(visibleCounts.max() ?? 0, 1) - } - - private var xDomain: ClosedRange { - let upperBound = trendPoints.map(\.weekIndex).max() ?? 1 - return 1...upperBound - } - - private var yAxisValues: [Int] { - if maxVisibleCount <= 4 { - return Array(0...maxVisibleCount) - } - - let step = max(Int(ceil(Double(maxVisibleCount) / 4)), 1) - var values = Array(stride(from: 0, through: maxVisibleCount, by: step)) - if values.last != maxVisibleCount { - values.append(maxVisibleCount) - } - return values - } - - private var yDomain: ClosedRange { - let upperBound = Double(maxVisibleCount) - let upperPadding = max(0.8, upperBound * 0.15) - let lowerPadding = max(0.6, upperBound * 0.08) - return -lowerPadding...(upperBound + upperPadding) - } - - private var hasVisibleActivity: Bool { - visibleCounts.contains { $0 > 0 } - } - - @ChartContentBuilder - private var createdSeries: some ChartContent { - ForEach(trendPoints) { point in - LineMark( - x: .value(String(localized: "profile_week_axis"), point.weekIndex), - y: .value(ProfileActivityType.created.title, point.createdCount), - series: .value(String(localized: "profile_activity_axis"), ProfileActivityType.created.title) - ) - .foregroundStyle(createdColor) - .lineStyle(StrokeStyle(lineWidth: 2.5)) - - PointMark( - x: .value(String(localized: "profile_week_axis"), point.weekIndex), - y: .value(ProfileActivityType.created.title, point.createdCount) - ) - .foregroundStyle(createdColor) - } - } - - @ChartContentBuilder - private var completedSeries: some ChartContent { - ForEach(trendPoints) { point in - LineMark( - x: .value(String(localized: "profile_week_axis"), point.weekIndex), - y: .value(ProfileActivityType.completed.title, point.completedCount), - series: .value(String(localized: "profile_activity_axis"), ProfileActivityType.completed.title) - ) - .foregroundStyle(completedColor) - .lineStyle(StrokeStyle(lineWidth: 2.5)) - - PointMark( - x: .value(String(localized: "profile_week_axis"), point.weekIndex), - y: .value(ProfileActivityType.completed.title, point.completedCount) - ) - .foregroundStyle(completedColor) - } - } - - private func legendItem(title: String, color: Color) -> some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 8, height: 8) - - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - } - } -} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index c8e1c519..459af9ac 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -81,6 +81,7 @@ struct ProfileView: View { } .padding(.horizontal, 16) } + .refreshable { viewModel.send(.refresh) } .frame(maxWidth: .infinity) .background(Color(.systemGroupedBackground)) .toolbar { @@ -105,13 +106,17 @@ struct ProfileView: View { updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) )) .environment(router) - case .activity(let activity): - ProfileActivityTodoDetailView(activity: activity) + case .activity(let todoId): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId, + showEditButton: false + )) } } - .onAppear { - viewModel.send(.onAppear) - } + .onAppear { viewModel.send(.onAppear) } .onChange(of: focused) { _, newValue in withAnimation { viewModel.send(.updateStatusTextFieldFocus(newValue)) @@ -147,28 +152,21 @@ struct ProfileView: View { quarterNavigator - if let quarter = viewModel.state.completionQuarter { - ProfileTrendChartView( - trendPoints: viewModel.state.completionQuarter?.weeklyTrendPoints ?? [], - selectedActivityTypes: viewModel.state.selectedActivityTypes - ) + if let quarter = viewModel.state.activityQuarter { ProfileHeatmapView( quarter: quarter, - selectedActivityTypes: viewModel.state.selectedActivityTypes, + selectedActivityKinds: viewModel.state.selectedActivityKinds, selectedDay: viewModel.state.selectedDay, - onSelectDay: { day in - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.send(.selectDay(day)) - } - } + onSelectDay: { viewModel.send(.selectDay($0)) } ) if let selectedDay = viewModel.state.selectedDay { selectedDayDetailSection(for: selectedDay) - .transition(.opacity) + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } } - } else { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 140) } } .padding(12) @@ -194,19 +192,32 @@ struct ProfileView: View { private var activityTypeSelector: some View { Menu { - ForEach(ProfileActivityType.allCases, id: \.self) { activityType in + ForEach(ActivityKindItem.selectableItems) { activityKindItem in Toggle( - activityType.title, + activityKindItem.title, isOn: Binding( - get: { viewModel.state.selectedActivityTypes.contains(activityType) }, + get: { + guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { + return false + } + return viewModel.state.selectedActivityKinds.contains(activityKind) + }, set: { _ in - viewModel.send(.toggleActivityType(activityType)) + guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { + return + } + viewModel.send(.toggleActivityKind(activityKind)) } ) ) .disabled( - viewModel.state.selectedActivityTypes.count == 1 - && viewModel.state.selectedActivityTypes.contains(activityType) + { + guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { + return false + } + return viewModel.state.selectedActivityKinds.count == 1 + && viewModel.state.selectedActivityKinds.contains(activityKind) + }() ) } } label: { @@ -319,7 +330,7 @@ struct ProfileView: View { } @ViewBuilder - private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { + private func selectedDayDetailSection(for day: ProfileActivityDay) -> some View { let activities = viewModel.selectedDayActivities VStack(alignment: .leading, spacing: 12) { @@ -336,33 +347,45 @@ struct ProfileView: View { } else { ForEach(activities) { activity in Button { - router.push(Path.activity(activity)) + if !activity.isDeleted { + router.push(Path.activity(activity.todoId)) + } } label: { - let todoCategoryItem = TodoCategoryItem(from: activity.todo.category) + let item = TodoCategoryItem(from: activity.category) + let rowColor = activity.isDeleted ? Color.secondary : .primary HStack(spacing: 8) { - Image(systemName: todoCategoryItem.symbolName) - .foregroundStyle(todoCategoryItem.color) + Image(systemName: item.symbolName) + .foregroundStyle(item.color) .frame(width: 20) - Text(activity.todo.title) + Text(activity.title) .font(.caption) .lineLimit(1) - Text(activity.activityLabel) - .font(.caption2) + .foregroundStyle(rowColor) + Text("#\(activity.number)") + .font(.caption) .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color(.systemGray4)) - ) + ForEach(activity.activityKindItems) { activityKindItem in + Text(activityKindItem.title) + .font(.caption2) + .foregroundStyle(activityKindItem.badgeColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(activityKindItem.badgeColor.opacity(0.14)) + ) + } Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + if !activity.isDeleted { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } } .contentShape(.rect) } .buttonStyle(.plain) + .disabled(activity.isDeleted) .padding(.vertical, 2) } } @@ -372,44 +395,6 @@ struct ProfileView: View { private enum Path: Hashable { case settings - case activity(ProfileSelectedDayActivity) - } -} - -private struct ProfileActivityTodoDetailView: View { - let activity: ProfileSelectedDayActivity - @State private var showInfo: Bool = false - - var body: some View { - TodoDetailContentView( - title: activity.todo.title, - content: activity.todo.content, - referenceItems: [:], - number: activity.todo.number, - activityLabel: activity.activityLabel - ) - .sheet(isPresented: $showInfo) { - infoSheetContent - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showInfo = true - } label: { - Image(systemName: "info.circle") - } - } - } - } - - private var infoSheetContent: some View { - TodoInfoSheetView( - createdAt: activity.todo.createdAt, - completedAt: activity.todo.completedAt, - dueDate: activity.todo.dueDate, - tags: activity.todo.tags - ) { - showInfo = false - } + case activity(String) } } diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 2370b434..fff76f17 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -299,12 +299,10 @@ private struct TodayTodoRow: View { .font(.headline) .foregroundStyle(Color(.label)) .lineLimit(1) - if let number = item.number { - Text("#\(number)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) - .fixedSize(horizontal: true, vertical: false) - } + Text("#\(item.number)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.gray) + .fixedSize(horizontal: true, vertical: false) Spacer() } diff --git a/Firebase/firestore.index.json b/Firebase/firestore.index.json index eeda2a59..9162f6c2 100644 --- a/Firebase/firestore.index.json +++ b/Firebase/firestore.index.json @@ -18,6 +18,66 @@ } ] }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "createdAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "deletedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "completedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "todoLists", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "deletedAt", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, { "collectionGroup": "todoLists", "queryScope": "COLLECTION", @@ -27,7 +87,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -45,7 +105,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -67,7 +127,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -85,7 +145,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -107,7 +167,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -129,7 +189,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -155,7 +215,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -177,7 +237,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -207,7 +267,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -229,7 +289,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -255,7 +315,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -277,7 +337,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -303,7 +363,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -329,7 +389,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -359,7 +419,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -385,7 +445,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -411,7 +471,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -441,7 +501,7 @@ "order": "ASCENDING" }, { - "fieldPath": "isDeleted", + "fieldPath": "deletedAt", "order": "ASCENDING" }, { @@ -455,7 +515,7 @@ ] }, { - "collectionGroup": "todoLists", + "collectionGroup": "notifications", "queryScope": "COLLECTION", "fields": [ { @@ -463,13 +523,13 @@ "order": "ASCENDING" }, { - "fieldPath": "createdAt", + "fieldPath": "receivedAt", "order": "ASCENDING" } ] }, { - "collectionGroup": "todoLists", + "collectionGroup": "notifications", "queryScope": "COLLECTION", "fields": [ { @@ -477,7 +537,7 @@ "order": "ASCENDING" }, { - "fieldPath": "createdAt", + "fieldPath": "receivedAt", "order": "DESCENDING" }, { @@ -487,7 +547,7 @@ ] }, { - "collectionGroup": "todoLists", + "collectionGroup": "notifications", "queryScope": "COLLECTION", "fields": [ { @@ -495,11 +555,11 @@ "order": "ASCENDING" }, { - "fieldPath": "updatedAt", - "order": "DESCENDING" + "fieldPath": "isRead", + "order": "ASCENDING" }, { - "fieldPath": "__name__", + "fieldPath": "receivedAt", "order": "ASCENDING" } ] @@ -512,75 +572,91 @@ "fieldPath": "isDeleted", "order": "ASCENDING" }, + { + "fieldPath": "isRead", + "order": "ASCENDING" + }, { "fieldPath": "receivedAt", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", "order": "ASCENDING" } ] - }, + } + ], + "fieldOverrides": [ { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ + "collectionGroup": "todoLists", + "fieldPath": "createdAt", + "indexes": [ { - "fieldPath": "isDeleted", - "order": "ASCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "receivedAt", - "order": "DESCENDING" + "order": "DESCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "__name__", - "order": "ASCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" } ] }, { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ + "collectionGroup": "todoLists", + "fieldPath": "updatedAt", + "indexes": [ { - "fieldPath": "isDeleted", - "order": "ASCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "isRead", - "order": "ASCENDING" + "order": "DESCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "receivedAt", - "order": "ASCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" } ] }, { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ + "collectionGroup": "todoLists", + "fieldPath": "completedAt", + "indexes": [ { - "fieldPath": "isDeleted", - "order": "ASCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "isRead", - "order": "ASCENDING" + "order": "DESCENDING", + "queryScope": "COLLECTION" }, { - "fieldPath": "receivedAt", - "order": "DESCENDING" + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" }, { - "fieldPath": "__name__", - "order": "ASCENDING" + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" } ] - } - ], - "fieldOverrides": [ + }, { "collectionGroup": "todoLists", - "fieldPath": "dueDate", + "fieldPath": "deletedAt", "indexes": [ { "order": "ASCENDING", @@ -602,7 +678,7 @@ }, { "collectionGroup": "todoLists", - "fieldPath": "isDeleted", + "fieldPath": "dueDate", "indexes": [ { "order": "ASCENDING", diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 5afb24da..b151a3c0 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -20,10 +20,10 @@ type FirestoreErrorLike = { // 큐에 적재된 알림 payload 검증 및 실제 푸시 발송 수행 export const sendPushNotification = onTaskDispatched({ - maxInstances: 2, + maxInstances: 10, region: "asia-northeast3", retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, - rateLimits: { maxDispatchesPerSecond: 200 }, + rateLimits: { maxDispatchesPerSecond: 10 }, }, async (req) => { const parsed = parseTaskPayload(req.data); diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index 87b8691b..afd085ad 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -33,7 +33,7 @@ import { } from "./fcm/schedule"; import { - cleanupSoftDeletedTodos + compactSoftDeletedTodos } from "./todo/cleanup"; import { @@ -47,8 +47,7 @@ import { import { requestTodoDeletion, - undoTodoDeletion, - completeTodoDeletion + undoTodoDeletion } from "./todo/deletion"; import { @@ -113,14 +112,13 @@ export { export { removeTodoNotificationDocuments, removeCompletedTodoNotificationRecords, - cleanupSoftDeletedTodos, cleanupNotificationDispatches, + compactSoftDeletedTodos, syncTodoNotificationCategory, requestMoveRemovedCategoryTodosToEtc, completeMoveRemovedCategoryTodosToEtc, requestTodoDeletion, undoTodoDeletion, - completeTodoDeletion, requestPushNotificationDeletion, undoPushNotificationDeletion, completePushNotificationDeletion, diff --git a/Firebase/functions/src/notification/deletion.ts b/Firebase/functions/src/notification/deletion.ts index 529e1eb4..49406b80 100644 --- a/Firebase/functions/src/notification/deletion.ts +++ b/Firebase/functions/src/notification/deletion.ts @@ -114,10 +114,10 @@ export const undoPushNotificationDeletion = onCall({ ); export const completePushNotificationDeletion = onTaskDispatched({ - maxInstances: 1, + maxInstances: 5, region: LOCATION, - retryConfig: {maxAttempts: 3, minBackoffSeconds: 5}, - rateLimits: {maxDispatchesPerSecond: 200}, + retryConfig: { maxAttempts: 3, minBackoffSeconds: 5}, + rateLimits: { maxDispatchesPerSecond: 5 }, }, async (request) => { const payload = parseDeletionPayload(request.data); diff --git a/Firebase/functions/src/todo/cleanup.ts b/Firebase/functions/src/todo/cleanup.ts index 4525c5b4..e556f118 100644 --- a/Firebase/functions/src/todo/cleanup.ts +++ b/Firebase/functions/src/todo/cleanup.ts @@ -4,16 +4,19 @@ import * as logger from "firebase-functions/logger"; import { toError } from "../common/error"; const LOCATION = "asia-northeast3"; -const QUERY_BATCH_SIZE = 100; +const CLEANUP_BATCH_SIZE = 200; +const TOMBSTONE_GRACE_PERIOD_HOURS = 24; -// soft delete Todo 문서의 실제 삭제 -export const cleanupSoftDeletedTodos = onSchedule({ +// 삭제 후 유예 기간이 지난 todo를 표시용 최소 필드만 남는 축약 문서 형태로 압축 +export const compactSoftDeletedTodos = onSchedule({ maxInstances: 1, region: LOCATION, - schedule: "0 0 * * *", - timeZone: "UTC" + schedule: "0 9 * * *", + timeZone: "Asia/Seoul" }, async () => { + const cutoff = new Date(Date.now() - (TOMBSTONE_GRACE_PERIOD_HOURS * 60 * 60 * 1000)); + try { let lastDocument: FirebaseFirestore.QueryDocumentSnapshot | undefined; @@ -21,9 +24,10 @@ export const cleanupSoftDeletedTodos = onSchedule({ while (true) { let query = admin.firestore() .collectionGroup("todoLists") - .where("isDeleted", "==", true) + .where("deletedAt", "<=", admin.firestore.Timestamp.fromDate(cutoff)) + .orderBy("deletedAt") .orderBy(admin.firestore.FieldPath.documentId()) - .limit(QUERY_BATCH_SIZE) + .limit(CLEANUP_BATCH_SIZE); if (lastDocument) { query = query.startAfter(lastDocument); } @@ -33,22 +37,35 @@ export const cleanupSoftDeletedTodos = onSchedule({ const batch = admin.firestore().batch(); snapshot.docs.forEach((document) => { - batch.delete(document.ref); + if (document.data()?.compactedAt) { + return; + } + batch.update(document.ref, { + compactedAt: admin.firestore.FieldValue.serverTimestamp(), + content: admin.firestore.FieldValue.delete(), + dueDate: admin.firestore.FieldValue.delete(), + isChecked: admin.firestore.FieldValue.delete(), + isCompleted: admin.firestore.FieldValue.delete(), + isDeleting: admin.firestore.FieldValue.delete(), + isPinned: admin.firestore.FieldValue.delete(), + isDeleted: admin.firestore.FieldValue.delete(), + tags: admin.firestore.FieldValue.delete() + }); }); await batch.commit(); - if (snapshot.size < QUERY_BATCH_SIZE) { return; } + if (snapshot.size < CLEANUP_BATCH_SIZE) { return; } lastDocument = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { logger.error( - "soft delete Todo cleanup 실패", + "soft deleted todo 축약 문서 압축 실패", toError(error), { collectionGroup: "todoLists", - filter: "isDeleted == true", - orderBy: "documentId", - queryBatchSize: QUERY_BATCH_SIZE + filter: `deletedAt <= now - ${TOMBSTONE_GRACE_PERIOD_HOURS}h`, + orderBy: ["deletedAt", "documentId"], + cleanupBatchSize: CLEANUP_BATCH_SIZE } ); } diff --git a/Firebase/functions/src/todo/deletion.ts b/Firebase/functions/src/todo/deletion.ts index 99753ca3..4de76355 100644 --- a/Firebase/functions/src/todo/deletion.ts +++ b/Firebase/functions/src/todo/deletion.ts @@ -1,20 +1,12 @@ import {onCall, HttpsError} from "firebase-functions/v2/https"; -import {onTaskDispatched} from "firebase-functions/v2/tasks"; -import {getFunctions} from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { FirestorePath } from "../common/firestorePath"; import {toError} from "../common/error"; const LOCATION = "asia-northeast3"; -const DELETE_DELAY_SECONDS = 5; const QUERY_BATCH_SIZE = 200; -type TodoDeletionPayload = { - userId: string; - todoId: string; -}; - export const requestTodoDeletion = onCall({ cors: true, maxInstances: 3, @@ -35,39 +27,33 @@ export const requestTodoDeletion = onCall({ const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); const todoSnapshot = await todoRef.get(); - if (!todoSnapshot.exists || todoSnapshot.data()?.isDeleted === true) { + if (!todoSnapshot.exists || todoSnapshot.data()?.deletedAt) { throw new HttpsError("not-found", "Todo를 찾을 수 없습니다."); } try { await todoRef.set({ - // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 soft delete 되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp(), - isDeleted: false + deletedAt: admin.firestore.FieldValue.serverTimestamp(), + isDeleting: admin.firestore.FieldValue.delete(), + isDeleted: admin.firestore.FieldValue.delete() }, {merge: true}); await updateNotificationsDeletionState( userId, todoId, { - deletingAt: admin.firestore.FieldValue.serverTimestamp(), - isDeleted: false + deletingAt: admin.firestore.FieldValue.delete(), + isDeleted: true } ); - - const queue = getFunctions().taskQueue( - `locations/${LOCATION}/functions/completeTodoDeletion` - ); - await queue.enqueue( - { userId, todoId }, - { scheduleDelaySeconds: DELETE_DELAY_SECONDS } - ); } catch (error) { const currentTodoSnapshot = await todoRef.get(); - if (currentTodoSnapshot.exists && currentTodoSnapshot.data()?.isDeleted !== true) { + if (currentTodoSnapshot.exists && !currentTodoSnapshot.data()?.deletedAt) { await todoRef.update({ - deletingAt: admin.firestore.FieldValue.delete() + deletedAt: null, + isDeleting: admin.firestore.FieldValue.delete(), + isDeleted: admin.firestore.FieldValue.delete() }); } @@ -112,10 +98,11 @@ export const undoTodoDeletion = onCall({ const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); const todoSnapshot = await todoRef.get(); - if (todoSnapshot.exists && todoSnapshot.data()?.isDeleted !== true) { + if (todoSnapshot.exists && !!todoSnapshot.data()?.deletedAt) { await todoRef.update({ - deletingAt: admin.firestore.FieldValue.delete(), - isDeleted: false + deletedAt: null, + isDeleting: admin.firestore.FieldValue.delete(), + isDeleted: admin.firestore.FieldValue.delete() }); } @@ -139,73 +126,6 @@ export const undoTodoDeletion = onCall({ } ); -export const completeTodoDeletion = onTaskDispatched({ - maxInstances: 1, - region: LOCATION, - retryConfig: {maxAttempts: 3, minBackoffSeconds: 5}, - rateLimits: {maxDispatchesPerSecond: 200}, - }, - async (request) => { - const payload = parseDeletionPayload(request.data); - if (!payload) { - logger.warn("유효하지 않은 todo 삭제 payload", request.data); - return; - } - - const { userId, todoId } = payload; - const todoRef = admin.firestore().doc(FirestorePath.todo(userId, todoId)); - - try { - const todoSnapshot = await todoRef.get(); - const deletingAt = todoSnapshot.data()?.deletingAt; - const isDeleted = todoSnapshot.data()?.isDeleted === true; - - if (!todoSnapshot.exists || !deletingAt || isDeleted) { - return; - } - - await todoRef.set({ - deletingAt: admin.firestore.FieldValue.delete(), - isDeleted: true, - updatedAt: admin.firestore.FieldValue.serverTimestamp() - }, {merge: true}); - - await updateNotificationsDeletionState( - userId, - todoId, - { - deletingAt: admin.firestore.FieldValue.delete(), - isDeleted: true - } - ); - } catch (error) { - logger.error("todo 최종 soft delete 실패", toError(error), { - userId, - todoId - }); - throw error; - } - } -); - -function parseDeletionPayload(data: unknown): TodoDeletionPayload | null { - const userId = typeof (data as TodoDeletionPayload | undefined)?.userId === "string" ? - (data as TodoDeletionPayload).userId.trim() : - ""; - const todoId = typeof (data as TodoDeletionPayload | undefined)?.todoId === "string" ? - (data as TodoDeletionPayload).todoId.trim() : - ""; - - if (!userId || !todoId) { - return null; - } - - return { - userId, - todoId - }; -} - async function updateNotificationsDeletionState( userId: string, todoId: string, diff --git a/Firebase/functions/src/todoCategory/update.ts b/Firebase/functions/src/todoCategory/update.ts index 9a673729..cd55eac0 100644 --- a/Firebase/functions/src/todoCategory/update.ts +++ b/Firebase/functions/src/todoCategory/update.ts @@ -66,10 +66,10 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ ); export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ - maxInstances: 1, + maxInstances: 2, region: LOCATION, retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, - rateLimits: { maxDispatchesPerSecond: 20 }, + rateLimits: { maxDispatchesPerSecond: 2 }, }, async (request) => { const taskData = parseTaskPayload(request.data); diff --git a/Firebase/functions/src/webPage/deletion.ts b/Firebase/functions/src/webPage/deletion.ts index 7a2c21d4..65c4c338 100644 --- a/Firebase/functions/src/webPage/deletion.ts +++ b/Firebase/functions/src/webPage/deletion.ts @@ -129,10 +129,10 @@ export const undoWebPageDeletion = onCall({ ); export const completeWebPageDeletion = onTaskDispatched({ - maxInstances: 1, + maxInstances: 5, region: LOCATION, retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, - rateLimits: { maxDispatchesPerSecond: 200 }, + rateLimits: { maxDispatchesPerSecond: 5 }, }, async (request) => { const payload = parseDeletionPayload(request.data); diff --git a/Firebase/functions/tsconfig.json b/Firebase/functions/tsconfig.json index 35842d81..e947fd62 100644 --- a/Firebase/functions/tsconfig.json +++ b/Firebase/functions/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "nodenext", "noImplicitReturns": true, "noUnusedLocals": true, + "rootDir": "src", "outDir": "lib", "sourceMap": true, "strict": true, diff --git a/README.md b/README.md index 86022028..1b72ef7b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Todo, 저장 링크, 오늘 할 일, 받은 알림, 누적 활동을 하나의 - 웹 페이지 저장 및 재열람 - 오늘 기준 우선 확인 Todo 요약 - 받은 푸시 알림 확인 및 Todo 연계 -- 분기별 활동 히트맵 및 주간 추이 차트 제공 +- 분기별 활동 히트맵 제공 - Google, GitHub, Apple 로그인 및 계정 연동 ## 주요 기능 @@ -101,7 +101,7 @@ Todo, 저장 링크, 오늘 할 일, 받은 알림, 누적 활동을 하나의 ### 프로필 및 설정 - 상태 메시지 직접 수정 -- 분기 이동 및 직접 선택, 생성/완료 활동 필터 기반 히트맵과 주간 추이 차트 제공 +- 분기 이동 및 직접 선택, 생성/완료 활동 필터 기반 히트맵 제공 - 테마 변경, 푸시 알림 시간 설정, 캐시 정리 기능 제공 - 설정 화면에서 앱 버전, 개인정보 처리방침, 베타 테스트 링크 확인 diff --git a/docs/AppStore_Hitmap.png b/docs/AppStore_Hitmap.png index 85f49845..18cd0542 100644 Binary files a/docs/AppStore_Hitmap.png and b/docs/AppStore_Hitmap.png differ diff --git a/docs/hitmap.png b/docs/hitmap.png index da650ba7..083f347e 100644 Binary files a/docs/hitmap.png and b/docs/hitmap.png differ