// // 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 String(localized: "하루") case .week: return String(localized: "주간") case .month: return String(localized: "월간") } } } 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 } /// 하루 단위 다짐의 "그날 집계 창". 마감 시각이 있으면 [하루 시작, 마감)으로 줄어든다. /// 마감은 그 논리적 하루(24시간) 안에서 그 시계 시각이 등장하는 지점이다 — /// 하루 시작보다 이른 시각은 다음 달력일 새벽을 뜻하고, 시작과 같은 시각은 /// +24시간(온전한 하루)으로 해석해 0길이 창이 생기지 않는다. /// 마감이 없으면(또는 하루 단위가 아니면) 하루 전체 범위 그대로. func dayMeasurementRange(forKey key: Date) -> Range { let full = math.dayRange(forKey: key) guard quest.hasDeadline else { return full } let cal = math.calendar var deadline = cal.date(byAdding: .minute, value: quest.deadlineMinutes, to: key) ?? full.upperBound if deadline <= full.lowerBound { deadline = cal.date(byAdding: .day, value: 1, to: deadline) ?? full.upperBound } return full.lowerBound.. Bool { switch quest.period { case .daily: return isActiveDay(math.dayKey(for: now)) case .weekly, .monthly: return true case .custom: return currentPeriodRange(now: now)?.contains(now) ?? false } } // 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 } // 마감 시각이 있으면 주기 자체가 [하루 시작, 마감)까지 — current()·judgment가 자동 반영 return dayMeasurementRange(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 ) } /// 목표 종료 판정 전용 진행률: 기준 시점(종료일의 하루 끝 또는 수동 종료 시각)까지로 계산. /// 주기 중간에 목표가 끝나는 경우를 공정하게 다루기 위해 current()와 두 가지가 다르다: /// 1) 값은 기준 시점까지의 누적으로 제한 — 종료일 이후 같은 주기의 기록이 판정에 섞이지 않음 /// 2) 주간/월간/특정 기간 다짐의 목표량(한도 포함)은 주기에서 경과한 날 수만큼 비례 축소 — /// 수요일에 끝나는 목표의 주간 다짐이 "남은 4일치 미달"로 억울하게 깎이지 않고, /// '이하 유지' 한도도 같은 비율로 줄여 남은 기간만큼 한도를 더 쓴 것으로 오판하지 않는다 /// 하루 단위 다짐은 판정일 자체가 주기이므로 목표량을 줄이지 않는다 (수동 종료가 낮이어도 /// 그날 목표는 온전히 채워야 달성 — 기존 동작 유지). func judgment(asOf reference: Date) -> QuestProgressResult? { guard let full = currentPeriodRange(now: reference) else { return nil } let clipped = full.lowerBound.. 0 && elapsedDays < totalDays ? quest.targetValue * Double(elapsedDays) / Double(totalDays) : quest.targetValue } return QuestProgressResult( value: value(in: clipped, now: reference), target: target, direction: quest.direction ) } // MARK: 하루/주간/월간 진행률 (CLAUDE.md §6.4 표시 구조) /// span의 실제 시각 범위 (하루 시작 시간·주 시작 요일 반영) func spanRange(_ span: StatSpan, now: Date = .now) -> Range { switch span { case .day: return math.dayRange(containing: now) case .week: return math.weekRange(containing: now) case .month: return math.monthRange(containing: now) } } /// span 구간에서의 값과, 주기 목표량을 span에 맞게 환산한 목표. /// 주기와 다른 span의 목표량은 비례 환산된 **페이스 가이드**일 뿐 실제 목표가 아니므로, /// '이상 달성'은 값을 환산 목표에서 캡해 초과분을 표기하지 않는다 — 주 2회 다짐을 오늘 /// 1회 수행한 것이 "하루 350%"(1 ÷ 2/7)로 표시되던 왜곡 방지. 다짐 자신의 주기 span /// (하루 다짐의 '하루', 주간 다짐의 '주간' 등)은 실제 목표라 100% 초과를 그대로 보여 준다. /// '이하 유지'는 캡하지 않는다 — 환산 한도 대비 하루 게이지의 초과(0%) 표시는 /// 주간 한도를 오늘 다 써 가고 있다는 페이스 경고로 유효하고, 값이 UI의 초과 판정 /// (value > target)에 그대로 쓰인다. func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult { let range = spanRange(span, now: now) let target = scaledTarget(for: span, range: range, now: now) var value = spanValue(for: span, range: range, now: now) if quest.direction == .atLeast, target > 0, usesScaledTarget(for: span) { value = min(value, target) } return QuestProgressResult(value: value, target: target, direction: quest.direction) } /// 이 span의 목표량이 주기 목표를 비례 환산한 값인지. /// 자기 주기와 같은 span은 원 목표 그대로이고, 특정 기간 다짐은 모든 span에서 /// 원 목표를 쓰므로(환산 없음) 항상 false — 실제 초과 달성 표기를 유지한다. private func usesScaledTarget(for span: StatSpan) -> Bool { switch quest.period { case .daily: return span != .day case .weekly: return span != .week case .monthly: return span != .month case .custom: return false } } /// span 구간의 집계 값. /// 주간·월간에서 '이상 달성' 하루 단위 다짐은 **하루별 기여를 그날의 목표치로 제한(캡)** 한 뒤 합산한다. /// 어떤 하루의 과도한 초과 달성이 다른 날의 미달을 덮어써 전체 진행률을 왜곡하는 것을 막기 위함이다. /// (예: 하루 목표 5분인데 어느 날 10분을 해도 그날은 5분어치만 인정 → 다른 날 미달이 가려지지 않음) /// 원본 기록은 그대로 두고 오직 이 계산에서만 상한을 적용하므로 기록·통계 화면의 총 누적값에는 영향이 없다. /// - 하루 span: 그날의 실제 누적을 그대로 써서 200% 같은 초과 달성 표기를 유지한다. /// - 하루 단위 다짐의 주/월 집계는 방향과 무관하게 **수행일 기록만** 합산한다 — /// 목표량(scaledTarget)이 수행일 수 기준이라 비수행일 기록을 값에 섞으면 분모·분자가 /// 어긋난다 (월수금 '이하 유지' 다짐이 목요일 기록 때문에 한도 초과로 표시되던 비대칭 수정). /// '이하 유지'는 캡 없이 수행일 값 그대로(한도 소비량). /// - 주/월/특정기간 단위 다짐: 기간 내 자유 누적이 정상 동작이라 캡 없이 전체 누적값을 쓴다. /// - 마감 시각(deadlineMinutes)이 있는 하루 단위 다짐: 모든 span에서 각 날의 값을 /// 그날의 집계 창(하루 시작~마감)으로 제한해 잰다 — 마감 뒤 기록은 집계에서 제외. private func spanValue(for span: StatSpan, range: Range, now: Date) -> Double { // 하루 span: 그날의 실제 누적. 마감 시각 다짐은 그날의 집계 창(마감까지)만 잰다. if span == .day { guard quest.hasDeadline else { return value(in: range, now: now) } return value(in: dayMeasurementRange(forKey: math.dayKey(for: range.lowerBound)), now: now) } // 주/월 span의 하루 단위 다짐은 방향과 무관하게 수행일만 하루별로 집계한다. // 주/월/기간 다짐은 기간 전체 누적 그대로. guard quest.period == .daily else { return value(in: range, now: now) } let caps = quest.direction == .atLeast && quest.targetValue > 0 let dailyTarget = quest.targetValue let agg = Aggregator(math: math) var sum = 0.0 for key in math.dayKeys(in: range) where isActiveDay(key) { let dayRange = dayMeasurementRange(forKey: key) let dayValue: Double switch quest.measure { case .time: dayValue = agg.seconds(for: quest.targetActions, in: dayRange, now: now) case .count: dayValue = Double(agg.count(for: quest.targetActions, in: dayRange)) } sum += caps ? min(dayValue, dailyTarget) : dayValue } return sum } /// span 구간의 실측 누적값 — 위젯 ③ 누적값 라벨용. /// 진행률 캡(하루 기여 제한)·적용일 여부와 무관한 "원본" 성격의 값이지만, /// 마감 시각 다짐은 각 날의 집계 창(마감까지)만 센다 — 마감 뒤 기록으로 /// 게이지는 0%인데 라벨 숫자만 늘어나는 모순을 막는다. 마감이 없으면 기존과 동일. func spanRawValue(_ span: StatSpan, now: Date = .now) -> Double { let range = spanRange(span, now: now) guard quest.hasDeadline else { return value(in: range, now: now) } let agg = Aggregator(math: math) var sum = 0.0 for key in math.dayKeys(in: range) { let dayRange = dayMeasurementRange(forKey: key) switch quest.measure { case .time: sum += agg.seconds(for: quest.targetActions, in: dayRange, now: now) case .count: sum += Double(agg.count(for: quest.targetActions, in: dayRange)) } } return sum } /// 주기 목표량을 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: - 연속 달성 /// 다짐의 연속 달성 정보. 문구는 "연속 3일 / 연속 3주 / 연속 2달" 형태만 사용한다 (스펙). struct QuestStreakInfo { enum Unit { case day, week, month } let count: Int let unit: Unit var label: String { switch unit { case .day: return String(localized: "연속 \(count)일") case .week: return String(localized: "연속 \(count)주") case .month: return String(localized: "연속 \(count)달") } } } extension QuestProgress { /// 연속 계산이 과거로 거슬러 올라가는 상한(일). 성능 보호용 — /// 이보다 긴 연속은 상한에서 잘린 값으로 표기된다 (하루 단위 400일 / 주 57주 / 달 13달 수준). private static let streakScanDays = 400 /// 현재 연속 달성 횟수. 표시 규칙: /// - 하루 단위: 적용일(매일/요일/날짜/몇 번째 요일)만 세어 "연속 N일". /// 예: 매주 수요일 다짐을 4주 연속 수행 → 연속 4일. /// - 주간/월간: 주기 전체 달성 여부로 "연속 N주 / N달". /// - 특정 기간: 반복이 없으므로 nil (표시 안 함). /// 진행 중인 오늘/이번 주기는 '이상 달성'이면 아직 못 채워도 끊기지 않고(건너뜀), /// 이미 채웠으면 포함한다. '이하 유지'는 한도를 넘는 순간 끊김, 한도 안이면 포함. /// /// 하한 = 목표 시작일: 이것은 행동의 연속이 아니라 "목표 안 다짐"의 연속이므로 /// 목표가 시작되기 전 날짜/주기는 세지 않는다. (특히 '이하 유지'는 기록 없는 날이 /// 전부 달성으로 집계되므로 시작일 하한이 없으면 과거 전체가 연속으로 잡힌다) func streak(now: Date = .now) -> QuestStreakInfo? { guard quest.targetValue > 0 else { return nil } switch quest.period { case .custom: return nil case .daily: return dayStreak(now: now) case .weekly: return periodStreak(unit: .week, now: now) case .monthly: return periodStreak(unit: .month, now: now) } } /// 스캔 구간의 기록을 논리적 하루 키로 한 번에 버킷팅한다. /// 하루마다 Aggregator로 전체 기록을 다시 훑는 대신(O(일수×기록수)) 기록을 한 번만 지나가 /// 연속 계산 전체가 O(기록수 + 일수)가 되게 하는 것이 핵심. private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] { var values: [Date: Double] = [:] // 마감 시각 다짐은 하루별 집계 창(마감까지)만 센다. 창 끝은 날짜별로 캐시해 // 기록마다 달력 연산이 반복되지 않게 한다. 마감이 없으면 분기 자체를 건너뜀(기존과 동일). let clips = quest.hasDeadline var windowEnds: [Date: Date] = [:] func windowEnd(_ key: Date) -> Date { if let cached = windowEnds[key] { return cached } let end = dayMeasurementRange(forKey: key).upperBound windowEnds[key] = end return end } for action in quest.targetActions { switch quest.measure { case .time: for session in action.sessions { let end = min(session.endAt ?? now, now) let start = max(session.startAt, lowerBound) guard start < end else { continue } for segment in math.splitByDay(start: start, end: end) { var upper = segment.range.upperBound if clips { // 조각의 시작은 항상 그날 창의 시작(하루 시작) 이후이므로 끝만 자르면 된다 upper = min(upper, windowEnd(segment.dayKey)) guard segment.range.lowerBound < upper else { continue } } values[segment.dayKey, default: 0] += upper.timeIntervalSince(segment.range.lowerBound) } } case .count: for entry in action.countEntries where entry.timestamp >= lowerBound && entry.timestamp <= now { let key = math.dayKey(for: entry.timestamp) if clips, entry.timestamp >= windowEnd(key) { continue } values[key, default: 0] += Double(entry.amount) } } } return values } private func isAchievedValue(_ value: Double) -> Bool { switch quest.direction { case .atLeast: return value >= quest.targetValue case .atMost: return value <= quest.targetValue } } /// 하루 단위: 오늘(적용일)부터 거꾸로 적용일만 세며 연속 달성 계산 private func dayStreak(now: Date) -> QuestStreakInfo { let cal = math.calendar let todayKey = math.dayKey(for: now) var lowerKey = cal.date(byAdding: .day, value: -Self.streakScanDays, to: todayKey)! if let goalStart = quest.goal?.startDate { lowerKey = max(lowerKey, math.dayKey(for: goalStart)) } let values = perDayValues(since: math.dayRange(forKey: lowerKey).lowerBound, now: now) var count = 0 var key = todayKey while key >= lowerKey { if isActiveDay(key) { if isAchievedValue(values[key] ?? 0) { count += 1 } else if key == todayKey && quest.direction == .atLeast { // 오늘은 아직 하루가 끝나지 않았으므로 미달이어도 끊김이 아님 — 어제까지로 센다 } else { break } } key = cal.date(byAdding: .day, value: -1, to: key)! } return QuestStreakInfo(count: count, unit: .day) } /// 주간/월간: 현재 주기부터 거꾸로 주기 전체 달성 여부로 연속 계산 private func periodStreak(unit: QuestStreakInfo.Unit, now: Date) -> QuestStreakInfo { let currentRange = unit == .week ? math.weekRange(containing: now) : math.monthRange(containing: now) let lowerBound = math.calendar.date( byAdding: .day, value: -Self.streakScanDays, to: currentRange.lowerBound )! let values = perDayValues(since: lowerBound, now: now) func value(in range: Range) -> Double { math.dayKeys(in: range).reduce(0) { $0 + (values[$1] ?? 0) } } var count = 0 var range = currentRange var isCurrentPeriod = true // 목표 시작일이 걸쳐 있는 주기(부분 주기)까지는 세고, 그 이전 주기에서 멈춘다 let goalStart = quest.goal?.startDate while range.lowerBound >= lowerBound { if let goalStart, range.upperBound <= goalStart { break } if isAchievedValue(value(in: range)) { count += 1 } else if isCurrentPeriod && quest.direction == .atLeast { // 진행 중인 이번 주기의 미달은 끊김이 아님 — 지난 주기부터 센다 } else { break } isCurrentPeriod = false // 이전 주기: 현재 주기 시작 직전 시각이 속한 주/달 let justBefore = range.lowerBound.addingTimeInterval(-1) range = unit == .week ? math.weekRange(containing: justBefore) : math.monthRange(containing: justBefore) } return QuestStreakInfo(count: count, unit: unit) } } // MARK: - 목표 평가 extension Goal { /// 소속 다짐들의 span 진행률 평균 (이하 유지 다짐은 100% 또는 0%로 반영됨). /// 모음 탭 카드·위젯·시리 단축어가 공통으로 사용하는 목표 단위 진행률. func combinedSpanRatio(_ span: StatSpan, math: DayMath = DayMath(), now: Date = .now) -> Double { let quests = sortedQuests guard !quests.isEmpty else { return 0 } // 하루 진행률은 오늘이 수행일인 다짐만 모수로 삼는다 (쉬는 요일 다짐 제외) let counted = span == .day ? quests.filter { QuestProgress(quest: $0, math: math).isScheduled(on: now) } : quests // 오늘 수행할 다짐이 하나도 없는 날은 0%가 아니라 완료 상태(100%)로 표기 guard !counted.isEmpty else { return 1 } let sum = counted.reduce(0.0) { $0 + QuestProgress(quest: $1, math: math).spanProgress(span, now: now).ratio } return sum / Double(counted.count) } /// 종료 판정용 다짐 평균 달성률 (0...1). /// 각 다짐은 달성이면 100%, 미달성이면 판정 주기 진행률만큼 반영 ('이하 유지' 초과는 0%). /// 기준일이 수행일이 아닌 다짐(judgment가 nil)은 달성으로 취급 — 기존 allSatisfy의 `?? true`와 동일. /// 주기 중간 종료의 값 제한·목표량 비례 축소는 QuestProgress.judgment(asOf:) 참고. func questAchievementRatio(math: DayMath = DayMath(), now: Date = .now) -> Double { guard !quests.isEmpty else { return 0 } let sum = quests.reduce(0.0) { partial, quest in guard let result = QuestProgress(quest: quest, math: math).judgment(asOf: now) else { return partial + 1 } return partial + (result.isAchieved ? 1 : min(max(result.ratio, 0), 1)) } return sum / Double(quests.count) } /// 다짐 평균 달성률이 목표의 달성 기준(achieveThresholdPercent) 이상인지 private func meetsAchieveThreshold(math: DayMath, now: Date) -> Bool { questAchievementRatio(math: math, now: now) * 100 >= Double(achieveThresholdPercent) - 0.0001 } /// 자동 판정의 기준 시점: **종료일이 속한 논리적 하루의 마지막 순간**. /// 판정을 실행하는 시점(now)이 아니라 종료일 기준으로 계산해야, 앱을 종료일 며칠 뒤에 /// 열어도 목표 기간 밖의 데이터(그날의 진행률)가 아니라 종료일까지의 기록으로 판정된다. /// 논리적 하루는 종료일의 달력일을 키로 직접 찾는다 — 정오 같은 대표 시각을 경유하면 /// '하루 시작 시간'이 그 시각보다 늦을 때 전날로 밀리는 엣지가 있다. /// 하루 시작 시간 설정으로 논리적 하루 끝이 실행 시점보다 늦을 수 있어 now로 상한을 둔다. private func judgmentReference(math: DayMath, now: Date) -> Date { guard let endDate else { return now } let dayKey = math.calendar.startOfDay(for: endDate) let dayEnd = math.dayRange(forKey: dayKey).upperBound.addingTimeInterval(-1) return min(dayEnd, now) } /// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신. /// (다짐 평균 달성률이 목표의 달성 기준 이상이면 달성으로 처리 — 기본 기준 100%) /// 판정 기준 시점은 종료일의 하루 끝 — 실행이 늦어도, 과거 날짜로 종료일을 고쳐도 결과가 같다. func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) { guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return } status = meetsAchieveThreshold(math: math, now: judgmentReference(math: math, now: now)) ? .achieved : .notAchieved } /// 수동 종료: 다짐 평균 달성률을 달성 기준과 비교해 판정 (다짐 없으면 nil 반환 → UI에서 직접 질문). /// 수동 종료 버튼은 종료일 없는 목표에만 있으므로 기준 시점은 "지금"(현재 주기)이 맞다. func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? { guard !quests.isEmpty else { return nil } status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved return status } }