-
Notifications
You must be signed in to change notification settings - Fork 0
[#200] ProfileView에 분기 활동을 주 단위로 그래프를 통해 보여주도록 구현한다 #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c6532e8
refactor: 뷰모델의 State를 그대로 쓰도록 수정 및 코드 개선
opficdev e80a1c1
ui: 이미지로 변경
opficdev aa7084b
refactor: 토글로 변경
opficdev b64a289
feat: 분기별 활등을 주차 대비 그래프로 보이도록 구현
opficdev 724e767
feat: 현재 분기가 아닐 경우 현재 분기로 원복할 수 있는 버튼 추가
opficdev 8c9a80b
style: 변수명 수정
opficdev 03797f2
feat: 년도와 분기를 선택할 수 있는 시트 구현
opficdev 94b6049
refactor: 버튼으로 분기 이동도 earlist Todo 범위 내에서 이동하도록 개선
opficdev 5a589e1
refactor: ProfileHeatmapView에 라벨 병합
opficdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // | ||
| // 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 | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,10 @@ final class ProfileViewModel: Store { | |
| var email: String = "" | ||
| 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<ProfileActivityType> = [.created, .completed] | ||
|
|
@@ -37,6 +40,12 @@ final class ProfileViewModel: Store { | |
| quarter: ProfileCompletionQuarter, | ||
| dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] | ||
| ) | ||
| 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?) | ||
|
|
@@ -47,6 +56,7 @@ final class ProfileViewModel: Store { | |
|
|
||
| enum SideEffect { | ||
| case fetchUserData | ||
| case fetchEarliestQuarterStart | ||
| case fetchCompletionQuarter(Date) | ||
| case updateStatusMessage(String) | ||
| case updateHeatmapActivityTypes(Set<ProfileActivityType>) | ||
|
|
@@ -60,41 +70,6 @@ final class ProfileViewModel: Store { | |
| private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase | ||
| private let calendar = Calendar.current | ||
|
|
||
| var quarterTitle: String { | ||
| guard let start = state.selectedQuarterStart else { return "" } | ||
| let year = calendar.component(.year, from: start) | ||
| let month = calendar.component(.month, from: start) | ||
| let quarter = ((month - 1) / 3) + 1 | ||
| return "\(year) Q\(quarter)" | ||
| } | ||
|
|
||
| var resetButtonEnabled: Bool { | ||
| !state.statusMessage.isEmpty && state.showDoneButton | ||
| } | ||
|
|
||
| var selectedQuarter: ProfileCompletionQuarter? { | ||
| state.completionQuarter | ||
| } | ||
|
|
||
| var selectedDayActivities: [ProfileSelectedDayActivity] { | ||
| 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) | ||
| } | ||
| } | ||
|
|
||
| var canMoveToPreviousQuarter: Bool { | ||
| canMoveToQuarter(offsetMonths: -3) | ||
| } | ||
|
|
||
| var canMoveToNextQuarter: Bool { | ||
| canMoveToQuarter(offsetMonths: 3) | ||
| } | ||
|
|
||
| init( | ||
| fetchUserDataUseCase: FetchUserDataUseCase, | ||
| fetchTodosUseCase: FetchTodosUseCase, | ||
|
|
@@ -116,15 +91,19 @@ final class ProfileViewModel: Store { | |
| switch action { | ||
| case .onAppear: | ||
| if state.selectedQuarterStart == nil { | ||
| guard let quarterStart = quarterStart(for: Date(), calendar: calendar) else { break } | ||
| 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) | ||
| if !settings.isEmpty { | ||
| state.selectedActivityTypes = settings | ||
| } | ||
| effects = [.fetchUserData] | ||
| if let selectedQuarterStart = state.selectedQuarterStart { | ||
| effects.append(.fetchCompletionQuarter(selectedQuarterStart)) | ||
| } | ||
|
|
@@ -137,6 +116,17 @@ final class ProfileViewModel: Store { | |
| state.email = profile.email | ||
| state.statusMessage = profile.statusMessage | ||
| state.avatarURL = profile.avatarURL | ||
| case .setEarliestQuarterStart(let quarterStart): | ||
| state.earliestQuarterStart = quarterStart | ||
| case .setQuarterPickerPresented(let isPresented): | ||
| state.showQuarterPicker = isPresented | ||
| case .setQuarterPickerYear(let year): | ||
| state.selectedQuarterPickerYear = year | ||
| case .openQuarterPicker: | ||
| if let selectedQuarterStart = state.selectedQuarterStart { | ||
| state.selectedQuarterPickerYear = calendar.component(.year, from: selectedQuarterStart) | ||
| } | ||
| state.showQuarterPicker = true | ||
| case .setCompletionQuarter(let quarterStart, let quarter, let dayActivitiesByDate): | ||
| guard state.selectedQuarterStart == quarterStart else { break } | ||
| state.completionQuarter = quarter | ||
|
|
@@ -149,6 +139,14 @@ final class ProfileViewModel: Store { | |
| } | ||
| case .setSelectedActivityForSheet(let activity): | ||
| state.selectedActivityForSheet = activity | ||
| case .selectQuarter(let quarterStart): | ||
| guard canSelectQuarter(quarterStart) else { break } | ||
| state.showQuarterPicker = false | ||
| updateSelectedQuarter(to: quarterStart, state: &state, effects: &effects) | ||
| case .moveToCurrentQuarter: | ||
| guard let currentQuarterStart = quarterStart(for: Date()), | ||
| state.selectedQuarterStart != currentQuarterStart else { break } | ||
| updateSelectedQuarter(to: currentQuarterStart, state: &state, effects: &effects) | ||
| case .moveQuarter(let delta): | ||
| guard let selectedQuarterStart = state.selectedQuarterStart else { break } | ||
| let monthDelta = 3 * delta | ||
|
|
@@ -157,15 +155,8 @@ final class ProfileViewModel: Store { | |
| value: monthDelta, | ||
| to: selectedQuarterStart | ||
| ) else { break } | ||
| let today = calendar.startOfDay(for: Date()) | ||
| guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } | ||
|
|
||
| state.selectedQuarterStart = nextQuarterStart | ||
| state.completionQuarter = nil | ||
| state.dayActivitiesByDate = [:] | ||
| state.selectedDay = nil | ||
| state.selectedActivityForSheet = nil | ||
| effects = [.fetchCompletionQuarter(nextQuarterStart)] | ||
| 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 { | ||
| break | ||
|
|
@@ -201,6 +192,15 @@ 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): | ||
| Task { | ||
| do { | ||
|
|
@@ -236,7 +236,79 @@ final class ProfileViewModel: Store { | |
| } | ||
| } | ||
|
|
||
| extension ProfileViewModel { | ||
| var quarterTitle: String { | ||
| guard let start = state.selectedQuarterStart else { return "" } | ||
| let year = calendar.component(.year, from: start) | ||
| let month = calendar.component(.month, from: start) | ||
| let quarter = ((month - 1) / 3) + 1 | ||
| return "\(year) Q\(quarter)" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| var selectedDayActivities: [ProfileSelectedDayActivity] { | ||
| 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) | ||
| } | ||
| } | ||
|
|
||
| var canMoveToPreviousQuarter: Bool { | ||
| canMoveToQuarter(offsetMonths: -3) | ||
| } | ||
|
|
||
| var canMoveToNextQuarter: Bool { | ||
| canMoveToQuarter(offsetMonths: 3) | ||
| } | ||
|
|
||
| var isViewingCurrentQuarter: Bool { | ||
| guard let selectedQuarterStart = state.selectedQuarterStart, | ||
| let currentQuarterStart = quarterStart(for: Date()) else { | ||
| return false | ||
| } | ||
| return selectedQuarterStart == currentQuarterStart | ||
| } | ||
|
|
||
| var availableQuarterYears: [Int] { | ||
| guard let earliestQuarterStart = state.earliestQuarterStart, | ||
| let currentQuarterStart = quarterStart(for: Date()) else { return [state.selectedQuarterPickerYear] } | ||
| let earliestYear = calendar.component(.year, from: earliestQuarterStart) | ||
| let currentYear = calendar.component(.year, from: currentQuarterStart) | ||
| return Array(stride(from: currentYear, through: earliestYear, by: -1)) | ||
| } | ||
|
|
||
| func quarterStartForPicker(quarter: Int) -> Date? { | ||
| quarterStart(year: state.selectedQuarterPickerYear, quarter: quarter) | ||
| } | ||
|
|
||
| func isQuarterSelectableForPicker(_ quarter: Int) -> Bool { | ||
| guard let quarterStart = quarterStartForPicker(quarter: quarter) else { return false } | ||
| return canSelectQuarter(quarterStart) | ||
| } | ||
|
|
||
| func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { | ||
| quarterStartForPicker(quarter: quarter) == state.selectedQuarterStart | ||
| } | ||
| } | ||
|
|
||
| private extension ProfileViewModel { | ||
| func updateSelectedQuarter( | ||
| to quarterStart: Date, | ||
| state: inout State, | ||
| effects: inout [SideEffect] | ||
| ) { | ||
| guard state.selectedQuarterStart != quarterStart else { return } | ||
| state.selectedQuarterStart = quarterStart | ||
| state.completionQuarter = nil | ||
| state.dayActivitiesByDate = [:] | ||
| state.selectedDay = nil | ||
| state.selectedActivityForSheet = nil | ||
| effects = [.fetchCompletionQuarter(quarterStart)] | ||
| } | ||
|
|
||
| func makeDayActivitiesByDate(from todos: [Todo]) -> [Date: [ProfileSelectedDayActivity]] { | ||
| var activitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] | ||
|
|
||
|
|
@@ -292,12 +364,23 @@ private extension ProfileViewModel { | |
| return page.items | ||
| } | ||
|
|
||
| func canMove(to quarterStart: Date, calendar: Calendar, today: Date) -> Bool { | ||
| guard let quarterEnd = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { | ||
| return false | ||
| } | ||
| let interval = DateInterval(start: quarterStart, end: quarterEnd) | ||
| return interval.contains(today) || quarterEnd <= today | ||
| func fetchEarliestQuarterStart() async throws -> Date { | ||
| let page = try await fetchTodosUseCase.execute( | ||
| TodoQuery( | ||
| sortTarget: .createdAt, | ||
| sortOrder: .oldest, | ||
| pageSize: 1 | ||
| ), | ||
| cursor: nil | ||
| ) | ||
| let baseDate = page.items.first?.createdAt ?? Date() | ||
| return quarterStart(for: baseDate) ?? calendar.startOfDay(for: baseDate) | ||
| } | ||
|
|
||
| func canSelectQuarter(_ quarterStart: Date) -> Bool { | ||
| guard let earliestQuarterStart = state.earliestQuarterStart, | ||
| let currentQuarterStart = self.quarterStart(for: Date()) else { return false } | ||
| return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart | ||
| } | ||
|
|
||
| func normalizeActivityTypes(_ rawValues: [String]) -> Set<ProfileActivityType> { | ||
|
|
@@ -375,7 +458,7 @@ private extension ProfileViewModel { | |
| return ProfileCompletionMonth(monthStart: monthStart, weeks: weeks) | ||
| } | ||
|
|
||
| func quarterStart(for date: Date, calendar: Calendar) -> Date? { | ||
| func quarterStart(for date: Date) -> Date? { | ||
| let month = calendar.component(.month, from: date) | ||
| let startMonth = ((month - 1) / 3) * 3 + 1 | ||
| var components = calendar.dateComponents([.year], from: date) | ||
|
|
@@ -384,14 +467,22 @@ private extension ProfileViewModel { | |
| return calendar.date(from: components) | ||
| } | ||
|
|
||
| func quarterStart(year: Int, quarter: Int) -> Date? { | ||
| guard (1...4).contains(quarter) else { return nil } | ||
| var components = DateComponents() | ||
| components.year = year | ||
| components.month = ((quarter - 1) * 3) + 1 | ||
| components.day = 1 | ||
| return calendar.date(from: components) | ||
| } | ||
|
|
||
| func canMoveToQuarter(offsetMonths: Int) -> Bool { | ||
| guard let selectedQuarterStart = state.selectedQuarterStart else { return false } | ||
| guard let targetQuarterStart = calendar.date( | ||
| byAdding: .month, value: offsetMonths, to: selectedQuarterStart) | ||
| else { | ||
| return false | ||
| } | ||
| let today = calendar.startOfDay(for: Date()) | ||
| return canMove(to: targetQuarterStart, calendar: calendar, today: today) | ||
| return canSelectQuarter(targetQuarterStart) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.