Skip to content

Commit d2c0c11

Browse files
committed
feat: tca 1차 구현
1 parent a9db3b8 commit d2c0c11

7 files changed

Lines changed: 1147 additions & 239 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//
2+
// ProfileFeature+Dependencies.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/15/26.
6+
//
7+
8+
import ComposableArchitecture
9+
import DevLogDomain
10+
11+
extension DependencyValues {
12+
var profileFetchUserDataUseCase: FetchUserDataUseCase {
13+
get { self[ProfileFetchUserDataKey.self] }
14+
set { self[ProfileFetchUserDataKey.self] = newValue }
15+
}
16+
17+
var profileFetchImageDataUseCase: FetchProfileImageDataUseCase {
18+
get { self[ProfileFetchImageDataKey.self] }
19+
set { self[ProfileFetchImageDataKey.self] = newValue }
20+
}
21+
22+
var profileFetchTodosUseCase: FetchTodosUseCase {
23+
get { self[ProfileFetchTodosKey.self] }
24+
set { self[ProfileFetchTodosKey.self] = newValue }
25+
}
26+
27+
var profileUpsertStatusMessageUseCase: UpsertStatusMessageUseCase {
28+
get { self[ProfileUpsertStatusMessageKey.self] }
29+
set { self[ProfileUpsertStatusMessageKey.self] = newValue }
30+
}
31+
32+
var profileFetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase {
33+
get { self[ProfileFetchHeatmapTypesKey.self] }
34+
set { self[ProfileFetchHeatmapTypesKey.self] = newValue }
35+
}
36+
37+
var profileUpdateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase {
38+
get { self[ProfileUpdateHeatmapTypesKey.self] }
39+
set { self[ProfileUpdateHeatmapTypesKey.self] = newValue }
40+
}
41+
}
42+
43+
private enum ProfileFetchUserDataKey: DependencyKey {
44+
static var liveValue: FetchUserDataUseCase {
45+
preconditionFailure("FetchUserDataUseCase must be provided.")
46+
}
47+
48+
static var testValue: FetchUserDataUseCase {
49+
liveValue
50+
}
51+
}
52+
53+
private enum ProfileFetchImageDataKey: DependencyKey {
54+
static var liveValue: FetchProfileImageDataUseCase {
55+
preconditionFailure("FetchProfileImageDataUseCase must be provided.")
56+
}
57+
58+
static var testValue: FetchProfileImageDataUseCase {
59+
liveValue
60+
}
61+
}
62+
63+
private enum ProfileFetchTodosKey: DependencyKey {
64+
static var liveValue: FetchTodosUseCase {
65+
preconditionFailure("FetchTodosUseCase must be provided.")
66+
}
67+
68+
static var testValue: FetchTodosUseCase {
69+
liveValue
70+
}
71+
}
72+
73+
private enum ProfileUpsertStatusMessageKey: DependencyKey {
74+
static var liveValue: UpsertStatusMessageUseCase {
75+
preconditionFailure("UpsertStatusMessageUseCase must be provided.")
76+
}
77+
78+
static var testValue: UpsertStatusMessageUseCase {
79+
liveValue
80+
}
81+
}
82+
83+
private enum ProfileFetchHeatmapTypesKey: DependencyKey {
84+
static var liveValue: FetchHeatmapActivityTypesUseCase {
85+
preconditionFailure("FetchHeatmapActivityTypesUseCase must be provided.")
86+
}
87+
88+
static var testValue: FetchHeatmapActivityTypesUseCase {
89+
liveValue
90+
}
91+
}
92+
93+
private enum ProfileUpdateHeatmapTypesKey: DependencyKey {
94+
static var liveValue: UpdateHeatmapActivityTypesUseCase {
95+
preconditionFailure("UpdateHeatmapActivityTypesUseCase must be provided.")
96+
}
97+
98+
static var testValue: UpdateHeatmapActivityTypesUseCase {
99+
liveValue
100+
}
101+
}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
//
2+
// ProfileFeature+Heatmap.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/15/26.
6+
//
7+
8+
import DevLogCore
9+
import DevLogDomain
10+
import Foundation
11+
12+
private struct ProfileHeatmapActivityCounts {
13+
var createdCount = 0
14+
var completedCount = 0
15+
var deletedCount = 0
16+
17+
mutating func increment(_ activityKind: ActivityKind) {
18+
switch activityKind {
19+
case .created:
20+
createdCount += 1
21+
case .completed:
22+
completedCount += 1
23+
case .deleted:
24+
deletedCount += 1
25+
}
26+
}
27+
}
28+
29+
private struct ProfileHeatmapActivityEntry {
30+
var todo: Todo
31+
var activityKinds: Set<ActivityKind>
32+
}
33+
34+
extension ProfileFeature {
35+
static func quarterStart(for date: Date) -> Date? {
36+
let month = Calendar.current.component(.month, from: date)
37+
let startMonth = ((month - 1) / 3) * 3 + 1
38+
var components = Calendar.current.dateComponents([.year], from: date)
39+
components.month = startMonth
40+
components.day = 1
41+
return Calendar.current.date(from: components)
42+
}
43+
44+
static func quarterStart(year: Int, quarter: Int) -> Date? {
45+
guard (1...4).contains(quarter) else { return nil }
46+
var components = DateComponents()
47+
components.year = year
48+
components.month = ((quarter - 1) * 3) + 1
49+
components.day = 1
50+
return Calendar.current.date(from: components)
51+
}
52+
53+
static func canSelectQuarter(_ quarterStart: Date, state: State) -> Bool {
54+
guard let earliestQuarterStart = state.earliestQuarterStart,
55+
let currentQuarterStart = self.quarterStart(for: Date()) else { return false }
56+
return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart
57+
}
58+
59+
static func normalizeActivityKinds(_ rawValues: [String]) -> Set<ActivityKind> {
60+
let selectableActivityKindRawValues = Set(ActivityKindItem.selectableItems.map(\.rawValue))
61+
62+
return Set(
63+
rawValues
64+
.compactMap(ActivityKind.init(rawValue:))
65+
.filter { selectableActivityKindRawValues.contains($0.rawValue) }
66+
)
67+
}
68+
69+
static func canMoveToQuarter(offsetMonths: Int, state: State) -> Bool {
70+
guard let selectedQuarterStart = state.selectedQuarterStart else { return false }
71+
guard let targetQuarterStart = Calendar.current.date(
72+
byAdding: .month,
73+
value: offsetMonths,
74+
to: selectedQuarterStart
75+
) else {
76+
return false
77+
}
78+
return canSelectQuarter(targetQuarterStart, state: state)
79+
}
80+
81+
static func fetchQuarterActivityData(
82+
from quarterStart: Date,
83+
fetchTodosUseCase: FetchTodosUseCase
84+
) async throws -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) {
85+
guard let nextQuarterStart = Calendar.current.date(byAdding: .month, value: 3, to: quarterStart) else {
86+
return (HeatmapQuarter(quarterStart: quarterStart, months: []), [:])
87+
}
88+
89+
async let createdTodoPage = fetchTodosUseCase.execute(
90+
TodoQuery(
91+
sortDateFrom: quarterStart,
92+
sortDateTo: nextQuarterStart,
93+
includesDeleted: true,
94+
sortTarget: .createdAt,
95+
pageSize: 100,
96+
fetchAllPages: true
97+
),
98+
cursor: nil
99+
)
100+
async let completedTodoPage = fetchTodosUseCase.execute(
101+
TodoQuery(
102+
sortDateFrom: quarterStart,
103+
sortDateTo: nextQuarterStart,
104+
includesDeleted: true,
105+
sortTarget: .completedAt,
106+
pageSize: 100,
107+
fetchAllPages: true
108+
),
109+
cursor: nil
110+
)
111+
async let deletedTodoPage = fetchTodosUseCase.execute(
112+
TodoQuery(
113+
sortDateFrom: quarterStart,
114+
sortDateTo: nextQuarterStart,
115+
includesDeleted: true,
116+
sortTarget: .deletedAt,
117+
pageSize: 100,
118+
fetchAllPages: true
119+
),
120+
cursor: nil
121+
)
122+
123+
let (createdTodoPageResult, completedTodoPageResult, deletedTodoPageResult) = try await (
124+
createdTodoPage,
125+
completedTodoPage,
126+
deletedTodoPage
127+
)
128+
return makeQuarterActivityData(
129+
createdTodos: createdTodoPageResult.items,
130+
completedTodos: completedTodoPageResult.items,
131+
deletedTodos: deletedTodoPageResult.items,
132+
quarterStart: quarterStart
133+
)
134+
}
135+
136+
static func makeQuarterActivityData(
137+
createdTodos: [Todo],
138+
completedTodos: [Todo],
139+
deletedTodos: [Todo],
140+
quarterStart: Date
141+
) -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) {
142+
var dailyCountsByDate: [Date: ProfileHeatmapActivityCounts] = [:]
143+
var activityEntriesByDate: [Date: [String: ProfileHeatmapActivityEntry]] = [:]
144+
145+
for todo in createdTodos {
146+
appendHeatmapActivity(
147+
todo: todo,
148+
kind: .created,
149+
occurredAt: todo.createdAt,
150+
dailyCountsByDate: &dailyCountsByDate,
151+
activityEntriesByDate: &activityEntriesByDate
152+
)
153+
}
154+
155+
for todo in completedTodos {
156+
guard let completedAt = todo.completedAt else { continue }
157+
appendHeatmapActivity(
158+
todo: todo,
159+
kind: .completed,
160+
occurredAt: completedAt,
161+
dailyCountsByDate: &dailyCountsByDate,
162+
activityEntriesByDate: &activityEntriesByDate
163+
)
164+
}
165+
166+
for todo in deletedTodos {
167+
guard let deletedAt = todo.deletedAt else { continue }
168+
appendHeatmapActivity(
169+
todo: todo,
170+
kind: .deleted,
171+
occurredAt: deletedAt,
172+
dailyCountsByDate: &dailyCountsByDate,
173+
activityEntriesByDate: &activityEntriesByDate
174+
)
175+
}
176+
177+
let quarter = HeatmapQuarter(
178+
quarterStart: quarterStart,
179+
months: makeActivityMonths(dailyCountsByDate: dailyCountsByDate, quarterStart: quarterStart)
180+
)
181+
let dayActivitiesByDate = activityEntriesByDate.mapValues { activityEntries in
182+
activityEntries.values.compactMap { activityEntry in
183+
HeatmapActivityItem(
184+
todo: activityEntry.todo,
185+
activityKinds: orderedActivityKinds(from: activityEntry.activityKinds)
186+
)
187+
}
188+
.sorted()
189+
}
190+
return (quarter, dayActivitiesByDate)
191+
}
192+
193+
private static func makeActivityMonths(
194+
dailyCountsByDate: [Date: ProfileHeatmapActivityCounts],
195+
quarterStart: Date
196+
) -> [HeatmapMonth] {
197+
let monthStarts = (0..<3).compactMap {
198+
Calendar.current.date(byAdding: .month, value: $0, to: quarterStart)
199+
}
200+
201+
return monthStarts.map { monthStart in
202+
makeActivityMonth(
203+
monthStart: monthStart,
204+
dailyCountsByDate: dailyCountsByDate
205+
)
206+
}
207+
}
208+
209+
private static func makeActivityMonth(
210+
monthStart: Date,
211+
dailyCountsByDate: [Date: ProfileHeatmapActivityCounts]
212+
) -> HeatmapMonth {
213+
guard let monthInterval = Calendar.current.dateInterval(of: .month, for: monthStart),
214+
let monthLastDay = Calendar.current.date(byAdding: .day, value: -1, to: monthInterval.end),
215+
let firstWeekInterval = Calendar.current.dateInterval(of: .weekOfYear, for: monthInterval.start),
216+
let lastWeekInterval = Calendar.current.dateInterval(of: .weekOfYear, for: monthLastDay) else {
217+
return HeatmapMonth(monthStart: monthStart, weeks: [])
218+
}
219+
220+
var days: [HeatmapDay] = []
221+
var cursor = firstWeekInterval.start
222+
while cursor < lastWeekInterval.end {
223+
let normalizedDate = Calendar.current.startOfDay(for: cursor)
224+
let isInMonth = Calendar.current.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month)
225+
let dailyCounts = dailyCountsByDate[normalizedDate] ?? ProfileHeatmapActivityCounts()
226+
let createdCount = isInMonth ? dailyCounts.createdCount : 0
227+
let completedCount = isInMonth ? dailyCounts.completedCount : 0
228+
let deletedCount = isInMonth ? dailyCounts.deletedCount : 0
229+
days.append(
230+
HeatmapDay(
231+
date: normalizedDate,
232+
createdCount: createdCount,
233+
completedCount: completedCount,
234+
deletedCount: deletedCount,
235+
isVisible: isInMonth
236+
)
237+
)
238+
guard let nextDay = Calendar.current.date(byAdding: .day, value: 1, to: cursor) else { break }
239+
cursor = nextDay
240+
}
241+
242+
var weeks: [[HeatmapDay]] = []
243+
var index = 0
244+
while index < days.count {
245+
let endIndex = min(index + 7, days.count)
246+
weeks.append(Array(days[index..<endIndex]))
247+
index += 7
248+
}
249+
250+
return HeatmapMonth(monthStart: monthStart, weeks: weeks)
251+
}
252+
253+
private static func appendHeatmapActivity(
254+
todo: Todo,
255+
kind: ActivityKind,
256+
occurredAt: Date,
257+
dailyCountsByDate: inout [Date: ProfileHeatmapActivityCounts],
258+
activityEntriesByDate: inout [Date: [String: ProfileHeatmapActivityEntry]]
259+
) {
260+
let dayStart = Calendar.current.startOfDay(for: occurredAt)
261+
var heatmapActivityCounts = dailyCountsByDate[dayStart] ?? ProfileHeatmapActivityCounts()
262+
heatmapActivityCounts.increment(kind)
263+
dailyCountsByDate[dayStart] = heatmapActivityCounts
264+
265+
var activityEntries = activityEntriesByDate[dayStart] ?? [:]
266+
var heatmapActivityEntry = activityEntries[todo.id] ?? ProfileHeatmapActivityEntry(
267+
todo: todo,
268+
activityKinds: []
269+
)
270+
heatmapActivityEntry.todo = todo
271+
heatmapActivityEntry.activityKinds.insert(kind)
272+
activityEntries[todo.id] = heatmapActivityEntry
273+
activityEntriesByDate[dayStart] = activityEntries
274+
}
275+
276+
private static func orderedActivityKinds(from activityKinds: Set<ActivityKind>) -> [ActivityKind] {
277+
let orderedActivityKinds: [ActivityKind] = [.created, .completed, .deleted]
278+
return orderedActivityKinds.filter { activityKinds.contains($0) }
279+
}
280+
}

0 commit comments

Comments
 (0)