Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4a2a144
feat: 매 분기마다의 Todo의 생성 / 완료에 대해 맵을 보여주도록 구성
opficdev Mar 1, 2026
ed9d1a1
fix: 년 분기가 달라질 때 패딩값이 자동으로 수정되는 현상 해결
opficdev Mar 1, 2026
f59a692
feat: 분기 단위로 Todo 생성 / 완료 히트맵을 보여주도록 구성
opficdev Mar 1, 2026
29afda9
fix: 분기 이동이 되지 않는 현상 수정
opficdev Mar 1, 2026
fe8d453
ui: 테두리로 구분하는 대신 채우기로 변경
opficdev Mar 1, 2026
c7e7c5c
feat: UserDefaults로 활동 설정값을 저장하여 영속성 보장
opficdev Mar 1, 2026
5bab2b9
ui: 맵을 탭하면 해당 날짜에 해당하는 활동을 보여주도록 추가
opficdev Mar 1, 2026
03e95fd
feat: 특정 활동을 탭하면 해당 활동의 상세 내용을 볼 수 있도록 추가
opficdev Mar 1, 2026
d442935
refactor: 비슷한 UI요소 컴포넌트화
opficdev Mar 1, 2026
30718ba
chore: 디렉토리명 오타 수정
opficdev Mar 1, 2026
968b077
ui: 년도가 상시 보이도록 추가
opficdev Mar 1, 2026
57fd641
refactor: 터치성 개선
opficdev Mar 1, 2026
da05b51
ui: 투명도 애니메이션만 적용
opficdev Mar 1, 2026
60ecf3f
ui: 상세 내용을 내비게이션해서 보여주도록 변경
opficdev Mar 2, 2026
b24d4c0
ui: TodoDetailContentView가 배경색을 가지도록 수정
opficdev Mar 2, 2026
b783d0b
refactor: 키워드 원문을 로그에서 보이지 않도록 하고, nil이 아닌 쿼리만 보이도록 개선
opficdev Mar 2, 2026
d3e575b
refactor: 계산 변수들은 State 밖으로 분리
opficdev Mar 2, 2026
ecd6a26
refactor: 오류 발생 시 얼럿을 띄우도록 개선
opficdev Mar 2, 2026
db2121e
refactor: monthMaxCount가 각 day 셀에 대해 반복적으로 계산되는 형태를 한번만 하도록 개선
opficdev Mar 2, 2026
22a27db
refactor: Calendar 중복 생성 제거
opficdev Mar 2, 2026
0fe1db6
refactor: 뷰에서 Presentation 책임 제거
opficdev Mar 2, 2026
a20eea8
fix: 활동이 있더라도 내림에 의해 0으로 계산되어 보이지 않는 형태 방지
opficdev Mar 2, 2026
033e5fe
refactor: 동일 로직 메서드화
opficdev Mar 2, 2026
31b211a
refactor: 중복 코드 최소화
opficdev Mar 2, 2026
c087c2b
refactor: 100개 단위로 페이지네이션 형태를 통해 모든 데이터 받아오기
opficdev Mar 2, 2026
65a49d1
refactor: 캐시 제거 후 딕셔너리로 데이터르 변환해 날짜 조회 시 성능 개선
opficdev Mar 2, 2026
49bfcb8
refactor: 좀 더 안전하도록 로직 수정
opficdev Mar 2, 2026
c1c6136
refactor: 불필요 메서드 제거
opficdev Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DevLog/App/Assembler/DomainAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ private extension DomainAssembler {
FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self))
}

container.register(FetchTodosByDateRangeUseCase.self) {
FetchTodosByDateRangeUseCaseImpl(container.resolve(TodoRepository.self))
}

container.register(FetchTodosByKeywordUseCase.self) {
FetchTodosByKeywordUseCaseImpl(container.resolve(TodoRepository.self))
}
Expand Down Expand Up @@ -154,5 +158,13 @@ private extension DomainAssembler {
container.register(UpdatePushNotificationQueryUseCase.self) {
UpdatePushNotificationQueryUseCaseImpl(container.resolve(UserPreferencesRepository.self))
}

container.register(FetchProfileHeatmapActivityTypesUseCase.self) {
FetchProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self))
}

container.register(UpdateProfileHeatmapActivityTypesUseCase.self) {
UpdateProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self))
}
}
}
9 changes: 9 additions & 0 deletions DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository {
static let pushSortOrder = "PushNotification.sortOption"
static let pushTimeFilter = "PushNotification.timeFilter"
static let pushUnreadOnly = "PushNotification.showUnreadOnly"
static let profileHeatmapActivityTypes = "Profile.heatmap.activityTypes"
}

private let store: UserDefaultsStore
Expand Down Expand Up @@ -92,4 +93,12 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository {
func setPushNotificationUnreadOnly(_ value: Bool) {
store.setBool(value, forKey: Key.pushUnreadOnly)
}

func profileHeatmapActivityTypes() -> [String] {
store.stringArray(forKey: Key.profileHeatmapActivityTypes)
}

func setProfileHeatmapActivityTypes(_ activityTypes: [String]) {
store.setStringArray(activityTypes, forKey: Key.profileHeatmapActivityTypes)
}
}
16 changes: 15 additions & 1 deletion DevLog/Domain/Entity/TodoQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,35 @@
// Created by opfic on 2/21/26.
//

import Foundation

struct TodoQuery {
let kind: TodoKind?
let keyword: String?
let isPinned: Bool?
let createdAtFrom: Date?
let createdAtTo: Date?
let createdAtDescending: Bool
let pageSize: Int
let fetchAllPages: Bool

init(
kind: TodoKind? = nil,
keyword: String? = nil,
isPinned: Bool? = nil,
pageSize: Int = 20
createdAtFrom: Date? = nil,
createdAtTo: Date? = nil,
createdAtDescending: Bool = true,
pageSize: Int = 20,
fetchAllPages: Bool = false
) {
self.kind = kind
self.keyword = keyword
self.isPinned = isPinned
self.createdAtFrom = createdAtFrom
self.createdAtTo = createdAtTo
self.createdAtDescending = createdAtDescending
self.pageSize = pageSize
self.fetchAllPages = fetchAllPages
}
}
3 changes: 3 additions & 0 deletions DevLog/Domain/Protocol/UserPreferencesRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ protocol UserPreferencesRepository {

func pushNotificationUnreadOnly() -> Bool
func setPushNotificationUnreadOnly(_ value: Bool)

func profileHeatmapActivityTypes() -> [String]
func setProfileHeatmapActivityTypes(_ activityTypes: [String])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// FetchTodosByDateRangeUseCase.swift
// DevLog
//
// Created by opfic on 3/1/26.
//

import Foundation

protocol FetchTodosByDateRangeUseCase {
func execute(from startDate: Date, to endDate: Date) async throws -> [Todo]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// FetchTodosByDateRangeUseCaseImpl.swift
// DevLog
//
// Created by opfic on 3/1/26.
//

import Foundation

final class FetchTodosByDateRangeUseCaseImpl: FetchTodosByDateRangeUseCase {
private let repository: TodoRepository

init(_ repository: TodoRepository) {
self.repository = repository
}

func execute(from startDate: Date, to endDate: Date) async throws -> [Todo] {
let query = TodoQuery(
createdAtFrom: startDate,
createdAtTo: endDate,
pageSize: 100,
fetchAllPages: true
)
let page = try await repository.fetchTodos(query, cursor: nil)
return page.items
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// FetchProfileHeatmapActivityTypesUseCase.swift
// DevLog
//
// Created by 최윤진 on 3/2/26.
//

protocol FetchProfileHeatmapActivityTypesUseCase {
func execute() -> [String]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// FetchProfileHeatmapActivityTypesUseCaseImpl.swift
// DevLog
//
// Created by 최윤진 on 3/2/26.
//

final class FetchProfileHeatmapActivityTypesUseCaseImpl: FetchProfileHeatmapActivityTypesUseCase {
private let repository: UserPreferencesRepository

init(_ repository: UserPreferencesRepository) {
self.repository = repository
}

func execute() -> [String] {
repository.profileHeatmapActivityTypes()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// UpdateProfileHeatmapActivityTypesUseCase.swift
// DevLog
//
// Created by 최윤진 on 3/2/26.
//

protocol UpdateProfileHeatmapActivityTypesUseCase {
func execute(_ activityTypes: [String])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// UpdateProfileHeatmapActivityTypesUseCaseImpl.swift
// DevLog
//
// Created by 최윤진 on 3/2/26.
//

final class UpdateProfileHeatmapActivityTypesUseCaseImpl: UpdateProfileHeatmapActivityTypesUseCase {
private let repository: UserPreferencesRepository

init(_ repository: UserPreferencesRepository) {
self.repository = repository
}

func execute(_ activityTypes: [String]) {
repository.setProfileHeatmapActivityTypes(activityTypes)
}
}
94 changes: 73 additions & 21 deletions DevLog/Infra/Service/TodoService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,22 @@ final class TodoService {
guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated }

let trimmedKeyword = query.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let logMessage = "Fetching todo page: kind=\(String(describing: query.kind)), "
+ "keyword=\(trimmedKeyword), "
+ "pinned=\(String(describing: query.isPinned)), "
+ "cursor=\(String(describing: cursor))"
logger.info(logMessage)
let logComponents: [String?] = [
"createdAtDescending=\(query.createdAtDescending)",
query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil,
query.kind != nil ? "kind=\(query.kind!.rawValue)" : nil,
query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil,
query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil,
query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil,
"pageSize=\(query.pageSize)",
query.fetchAllPages ? "fetchAllPages=true" : nil,
cursor != nil ? "cursor=\(cursor!)" : nil
]
logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))")

var firestoreQuery: Query = store
.collection("users/\(uid)/todoLists/")
.order(by: "createdAt", descending: true)
.order(by: "createdAt", descending: query.createdAtDescending)
.order(by: FieldPath.documentID())

if let kind = query.kind {
Expand All @@ -39,30 +46,64 @@ final class TodoService {
firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: isPinned)
}

if let createdAtFrom = query.createdAtFrom {
firestoreQuery = firestoreQuery.whereField(
"createdAt",
isGreaterThanOrEqualTo: Timestamp(date: createdAtFrom)
)
}

if let createdAtTo = query.createdAtTo {
firestoreQuery = firestoreQuery.whereField(
"createdAt",
isLessThan: Timestamp(date: createdAtTo)
)
}

if trimmedKeyword.isEmpty {
if query.fetchAllPages {
var allItems: [TodoResponse] = []
var pageCursor = cursor

while true {
var pageQuery = firestoreQuery
if let pageCursor {
pageQuery = pageQuery.start(after: [
Timestamp(date: pageCursor.createdAt),
pageCursor.documentID
])
}

pageQuery = pageQuery.limit(to: query.pageSize)
let snapshot = try await pageQuery.getDocuments()
allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) })

guard snapshot.documents.count == query.pageSize else {
break
}

guard let lastDocument = snapshot.documents.last,
let nextCursor = makeCursor(from: lastDocument) else {
break
}

pageCursor = nextCursor
}

return TodoPageResponse(items: allItems, nextCursor: nil)
}

if let cursor {
firestoreQuery = firestoreQuery.start(after: [
Timestamp(date: cursor.createdAt),
cursor.documentID
])
}

let snapshot = try await firestoreQuery
.limit(to: query.pageSize)
.getDocuments()

firestoreQuery = firestoreQuery.limit(to: query.pageSize)
let snapshot = try await firestoreQuery.getDocuments()
let items = snapshot.documents.compactMap { makeResponse(from: $0) }

let nextCursor: TodoCursorDTO? = snapshot.documents.last.flatMap { document in
guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else {
return nil
}

return TodoCursorDTO(
createdAt: createdAt.dateValue(),
documentID: document.documentID
)
}
let nextCursor = snapshot.documents.last.flatMap { makeCursor(from: $0) }

return TodoPageResponse(items: items, nextCursor: nextCursor)
}
Expand Down Expand Up @@ -140,6 +181,17 @@ final class TodoService {
}

private extension TodoService {
func makeCursor(from document: QueryDocumentSnapshot) -> TodoCursorDTO? {
guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else {
return nil
}

return TodoCursorDTO(
createdAt: createdAt.dateValue(),
documentID: document.documentID
)
}

func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? {
makeResponse(documentID: snapshot.documentID, data: snapshot.data())
}
Expand Down
20 changes: 20 additions & 0 deletions DevLog/Presentation/Structure/Profile/ProfileActivityType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// 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 "생성"
case .completed: return "완료"
}
}
}
15 changes: 15 additions & 0 deletions DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// ProfileCompletionDay.swift
// DevLog
//
// Created by opfic on 3/2/26.
//

import Foundation

struct ProfileCompletionDay: Hashable {
let date: Date
let createdCount: Int
let completedCount: Int
let isInMonth: Bool
}
14 changes: 14 additions & 0 deletions DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// ProfileCompletionMonth.swift
// DevLog
//
// Created by opfic on 3/2/26.
//

import Foundation

struct ProfileCompletionMonth: Identifiable, Hashable {
var id: Date { monthStart }
let monthStart: Date
let weeks: [[ProfileCompletionDay]]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// ProfileCompletionQuarter.swift
// DevLog
//
// Created by opfic on 3/2/26.
//

import Foundation

struct ProfileCompletionQuarter: Identifiable, Hashable {
let quarterStart: Date
let months: [ProfileCompletionMonth]

var id: Date { quarterStart }
}
Loading