Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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 }
Expand All @@ -19,4 +24,28 @@ struct ProfileCompletionQuarter: Identifiable, Hashable {
.map { $0.createdCount + $0.completedCount }
.max() ?? 0
}

static func makeWeeklyTrendPoints(
from months: [ProfileCompletionMonth],
calendar: Calendar
) -> [ProfileWeeklyTrendPoint] {
let days = months
.flatMap(\.weeks)
.flatMap { $0 }
.filter(\.isInMonth)
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 }
)
}
}
Comment thread
opficdev marked this conversation as resolved.
}
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
}
}
}
2 changes: 1 addition & 1 deletion DevLog/Presentation/Structure/RecentTodoItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// RecentTodoItem.swift
// DevLog
//
// Created by Codex on 3/6/26.
// Created by opfic on 3/6/26.
//

import Foundation
Expand Down
201 changes: 146 additions & 55 deletions DevLog/Presentation/ViewModel/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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?)
Expand All @@ -47,6 +56,7 @@ final class ProfileViewModel: Store {

enum SideEffect {
case fetchUserData
case fetchEarliestQuarterStart
case fetchCompletionQuarter(Date)
case updateStatusMessage(String)
case updateHeatmapActivityTypes(Set<ProfileActivityType>)
Expand All @@ -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,
Expand All @@ -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))
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

분기(Quarter)를 나타내는 문자열 "Q"가 하드코딩되어 있습니다. Localizable.xcstringsQ%lld 키가 추가되었으므로, 이를 활용하여 지역화(Localization)를 지원하도록 수정하는 것이 좋습니다. 이렇게 하면 다른 언어로 번역이 필요할 때 쉽게 대응할 수 있습니다.

Suggested change
return "\(year) Q\(quarter)"
return "\(year) \(String(format: NSLocalizedString("Q%lld", comment: ""), quarter))"

}

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]] = [:]

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Loading