// // QuestProgress.swift // Haru_Danim // // 다짐 진행률 계산 (CLAUDE.md §3.3, §6.4) // import Foundation /// 하루/주간/월간 표시 구간 enum StatSpan: String, CaseIterable, Identifiable { case day, week, month var id: String { rawValue } var label: String { switch self { case .day: return "하루" case .week: return "주간" case .month: return "월간" } } } struct QuestProgressResult { let value: Double let target: Double let direction: QuestDirection /// 게이지 표시용 비율 (0...1). /// '이하 유지'는 기준을 넘지 않는 동안 100%(달성 유지), 초과하는 순간 0%(실패)로 취급. var ratio: Double { switch direction { case .atLeast: guard target > 0 else { return 0 } return min(value / target, 1) case .atMost: return value <= target ? 1 : 0 } } /// 퍼센트 문구 표시용 값. '이상 달성'은 100%를 넘을 수 있고, '이하 유지'는 100% 또는 0%. var displayRatio: Double { switch direction { case .atLeast: return target > 0 ? value / target : 0 case .atMost: return value <= target ? 1 : 0 } } /// 현재 시점 기준 달성 여부 var isAchieved: Bool { switch direction { case .atLeast: return value >= target case .atMost: return value <= target } } } struct QuestProgress { let quest: Quest let math: DayMath init(quest: Quest, math: DayMath = DayMath()) { self.quest = quest self.math = math } // MARK: 적용일 판정 /// 하루 단위 다짐에서 해당 논리적 하루가 적용일인지 func isActiveDay(_ dayKey: Date) -> Bool { guard quest.period == .daily else { return true } let cal = math.calendar switch quest.scheduleMode { case .everyDay: return true case .weekdays: return quest.weekdays.contains(cal.component(.weekday, from: dayKey)) case .monthDays: return quest.monthDays.contains(cal.component(.day, from: dayKey)) case .ordinalWeekday: return cal.component(.weekday, from: dayKey) == quest.ordinalWeekday && cal.component(.weekdayOrdinal, from: dayKey) == quest.ordinalWeek } } func activeDayCount(in range: Range) -> Int { math.dayKeys(in: range).filter(isActiveDay).count } // MARK: 현재 주기 진행률 /// 현재 주기의 실제 시각 범위. 하루 단위 다짐이 오늘 적용일이 아니면 nil. func currentPeriodRange(now: Date = .now) -> Range? { switch quest.period { case .daily: let key = math.dayKey(for: now) guard isActiveDay(key) else { return nil } return math.dayRange(forKey: key) case .weekly: return math.weekRange(containing: now) case .monthly: return math.monthRange(containing: now) case .custom: guard let start = quest.customStart, let end = quest.customEnd else { return nil } let cal = math.calendar let lower = math.dayRange(forKey: cal.startOfDay(for: start)).lowerBound let upper = math.dayRange(forKey: cal.startOfDay(for: end)).upperBound guard lower < upper else { return nil } return lower.., now: Date = .now) -> Double { let agg = Aggregator(math: math) switch quest.measure { case .time: return agg.seconds(for: quest.targetActions, in: range, now: now) case .count: return Double(agg.count(for: quest.targetActions, in: range)) } } /// 현재 주기의 진행률 func current(now: Date = .now) -> QuestProgressResult? { guard let range = currentPeriodRange(now: now) else { return nil } return QuestProgressResult( value: value(in: range, now: now), target: quest.targetValue, direction: quest.direction ) } // MARK: 하루/주간/월간 진행률 (CLAUDE.md §6.4 표시 구조) /// span 구간에서의 값과, 주기 목표량을 span에 맞게 환산한 목표 func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult { let range: Range switch span { case .day: range = math.dayRange(containing: now) case .week: range = math.weekRange(containing: now) case .month: range = math.monthRange(containing: now) } return QuestProgressResult( value: value(in: range, now: now), target: scaledTarget(for: span, range: range, now: now), direction: quest.direction ) } /// 주기 목표량을 span 길이에 맞춰 환산 private func scaledTarget(for span: StatSpan, range: Range, now: Date) -> Double { let target = quest.targetValue let daysInRange = math.dayKeys(in: range).count switch quest.period { case .daily: switch span { case .day: return target case .week, .month: return target * Double(max(activeDayCount(in: range), 1)) } case .weekly: switch span { case .day: return target / 7 case .week: return target case .month: return target * Double(daysInRange) / 7 } case .monthly: let daysInMonth = Double(math.dayKeys(in: math.monthRange(containing: now)).count) switch span { case .day: return target / daysInMonth case .week: return target * 7 / daysInMonth case .month: return target } case .custom: return target } } } // MARK: - 목표 평가 extension Goal { /// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신. /// (다짐 전체가 마지막 주기 기준으로 달성 상태이면 달성으로 처리) func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) { guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return } let allAchieved = quests.allSatisfy { quest in QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true } status = allAchieved ? .achieved : .notAchieved } /// 수동 종료: 하위 다짐 전체 달성 여부로 판정 (다짐 없으면 nil 반환 → UI에서 직접 질문) func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? { guard !quests.isEmpty else { return nil } let allAchieved = quests.allSatisfy { quest in QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true } status = allAchieved ? .achieved : .notAchieved return status } }