mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 2567b53b71 fix: correct daily goal progress calculation and optimize widget refresh logic
- 목표 하루 진행률은 오늘이 수행일인 다짐만 모수로 계산 (요일·날짜 지정으로
  오늘 쉬는 다짐은 분모에서 제외, QuestProgress.isScheduled 신설)
- 오늘 수행할 다짐이 하나도 없는 날은 0% 대신 100%(완료 상태)로 표기
- 주간/월간 진행률은 기존대로 전체 다짐 기준 유지
- 이벤트 기반 위젯 새로 고침: 앱 내 모든 핵심 Mutation(측정 시작/종료,
  횟수 추가, 기록 추가·수정·삭제, 행동·꼬리표·목표·다짐 CRUD, 순서 변경,
  목표 상태 변경)의 끝단에서 DataChange.commit() 호출 — 저장 즉시 확정 후
  Live Activity·위젯 타임라인·워치 스냅숏을 한 번에 동기화
- 주기적 백그라운드 새로 고침 없이 변경 시점에만 갱신해 WidgetKit
  업데이트 예산 절약 (타이머 표시는 기존 Text(style: .timer)로 시스템이
  자체 렌더링하므로 타임라인 소모 없음)
- 하루 시작 시간·주 시작 요일 설정 변경 시에도 위젯 집계 갱신
- 시뮬레이터 검증: 월·수·금 다짐이 있는 목표의 토요일 하루 진행률이
  33%가 아닌 50%(오늘 수행 다짐 2개 평균)로 표시됨 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-11 08:30:12 +09:00

233 lines
8.5 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: 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<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
}
}