Weekly/monthly progress for a daily quest summed each day's raw value into the numerator, so one over-achieving day leaked its overage into the span total. Example: two daily quests with a 5-minute target; on one day quest A did 5 min (100%) and quest B did 10 min (200%). Both are "done" for that day, but the weekly ratio counted quest B as 600s vs quest A's 300s — B carried double the weight, and its over-achievement masked other days' shortfalls and could push per-quest weekly display past 100%. spanProgress now aggregates a daily 'atLeast' quest's week/month value as the sum of per-day values each capped at that day's target (min(dayValue, target)), via a new private spanValue(for:range:now:). This equals averaging per-day capped ratios, so a single 200% day contributes exactly one day's worth — no day can cover for another, and per-quest weekly/monthly display never exceeds 100%. Scope is deliberately narrow: - day span is untouched — a single day still shows 200% over-achievement. - 'atMost' (stay-under) quests keep their existing per-day 100%/0% logic; capping the value would make them always pass, so they use the raw value unchanged — the two rules coexist without contradiction. - weekly/monthly/custom quests accumulate freely within their period, so no per-day cap applies to them. Only the progress calculation changed; raw records are never altered, so the records/stats screens still show the true 10-minute total. Verified in the simulator with a temporary isolated in-memory self-test (removed before commit): quest A (100%) and quest B (200%) produce identical weekly ratios (0.1429 each), quest B's weekly display stays <= 100%, and its day-span display remains 200%. Build succeeds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
264 lines
10 KiB
Swift
264 lines
10 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)
|
|
}
|
|
|
|
/// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신.
|
|
/// (다짐 전체가 마지막 주기 기준으로 달성 상태이면 달성으로 처리)
|
|
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
|
|
}
|
|
}
|