mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 85a98c1ab0 feat(goal): configurable achievement threshold per goal
목표 종료 판정이 '다짐 전체 달성'으로 고정되어 있던 것을, 목표를 만들 때
달성 판정 기준(%)을 정할 수 있게 확장. 다짐들의 평균 달성률(달성=100%,
미달성=현재 주기 진행률)이 기준 이상이면 달성 완료로 판정한다.

- Goal.achieveThresholdPercent (기본 100 = 기존 동작 유지, CloudKit 호환)
- evaluateIfEnded/manualFinish가 questAchievementRatio ≥ 기준으로 판정
- 목표 추가/수정 화면에 '달성 판정 기준' 슬라이더 (10~100%, 5% 단위)
- 수동 종료 안내 문구에 기준이 100% 미만이면 해당 기준 표기
- 검증용 런치 인자 -goalShowEditor 추가
- 그 외 진행률 표시·연산은 변경 없음

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 18:24:05 +09:00

277 lines
11 KiB
Swift

//
// 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<Date>) -> Int {
math.dayKeys(in: range).filter(isActiveDay).count
}
/// ( ).
/// / , .
func isScheduled(on now: Date) -> 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<Date>? {
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..<upper
}
}
func value(in range: Range<Date>, 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<Date>
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: spanValue(for: span, range: range, now: now),
target: scaledTarget(for: span, range: range, now: now),
direction: quest.direction
)
}
/// span .
/// · ' ' ** ()** .
/// .
/// (: 5 10 5 )
/// · .
/// - span: 200% .
/// - ' '(atMost): (100%/0%) ( ).
/// - // : .
private func spanValue(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
guard span != .day,
quest.period == .daily,
quest.direction == .atLeast,
quest.targetValue > 0
else {
return value(in: range, now: now)
}
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 = math.dayRange(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 += min(dayValue, dailyTarget)
}
return sum
}
/// span
private func scaledTarget(for span: StatSpan, range: Range<Date>, 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 {
/// 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%).
/// (current nil) allSatisfy `?? true` .
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).current(now: 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
}
/// .
/// ( 100%)
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
status = meetsAchieveThreshold(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
}
}