mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 78bad15852 feat(1.5-p3): 건강 다짐 — 8지표+운동 종목 20종, 값 공급 이음새로 §4.2 수학 무변경
- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화
- HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기)
- QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린
- 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용)
- §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④)
  +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩
- 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5),
  Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 06:07:25 +09:00

616 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
/// " ". [ , ) .
/// (24)
/// ,
/// +24( ) 0 .
/// ( ) .
func dayMeasurementRange(forKey key: Date) -> Range<Date> {
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..<min(deadline, full.upperBound)
}
/// ( ).
/// / , .
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 }
// [ , ) 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..<upper
}
}
/// (1.5) HealthCache ,
/// Aggregator . (··) .
func value(in range: Range<Date>, now: Date = .now) -> Double {
if let health = quest.healthTarget {
return HealthQuestValues.sum(target: health, quest: quest, in: range, math: math)
}
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..<min(full.upperBound, reference)
guard clipped.lowerBound < clipped.upperBound else { return nil }
let target: Double
switch quest.period {
case .daily:
target = quest.targetValue
case .weekly, .monthly, .custom:
let totalDays = math.dayKeys(in: full).count
let elapsedDays = math.dayKeys(in: clipped).count
target = totalDays > 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<Date> {
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<Date>, 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
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
// value(in:) Aggregator
// , (1.5)
let dayValue = value(in: dayMeasurementRange(forKey: key), now: now)
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
}
// MARK: (§4.2)
/// / ' ' .
/// " / " ( · · ·)
/// (combinedSpanRatio) .
/// spanProgress ·(judgment)·(streak) .
///
/// '' (
/// . ,
/// "now " ).
/// ( )· ( )·' '( 100%) .
func isPeriodFulfilled(asOf now: Date = .now) -> Bool {
guard quest.direction == .atLeast, quest.targetValue > 0 else { return false }
switch quest.period {
case .daily, .custom: return false
case .weekly, .monthly: break
}
guard let range = currentPeriodRange(now: now) else { return false }
let clipped = range.lowerBound..<min(range.upperBound, math.dayRange(containing: now).upperBound)
return value(in: clipped, now: now) >= quest.targetValue
}
/// isPeriodFulfilled true
var periodFulfilledLabel: String {
quest.period == .monthly ? String(localized: "이번 달 달성") : String(localized: "이번 주 달성")
}
/// 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: -
/// . " 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] {
// 1.5: ( ).
// (values[key] ?? 0) .
if let health = quest.healthTarget {
var values: [Date: Double] = [:]
let cal = math.calendar
let lowerKey = math.dayKey(for: lowerBound)
var key = math.dayKey(for: now)
while key >= lowerKey {
if let value = HealthQuestValues.dayValue(target: health, quest: quest, dayKey: key) {
values[key] = value
}
guard let previous = cal.date(byAdding: .day, value: -1, to: key) else { break }
key = previous
}
return values
}
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<Date>) -> 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 }
// ( ).
// 1.5 §8: ( )
// (0) ( )
let counted = quests.filter { quest in
if quest.isHealthQuest && !HealthSupport.deviceAvailable { return false }
guard span == .day else { return true }
return QuestProgress(quest: quest, math: math).isScheduled(on: now)
}
// 0% (100%)
guard !counted.isEmpty else { return 1 }
let sum = counted.reduce(0.0) { partial, quest in
let progress = QuestProgress(quest: quest, math: math)
// / (1.0)
// " / " (§4.2 )
if span == .day, progress.isPeriodFulfilled(asOf: now) {
return partial + 1
}
return partial + progress.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 }
// 1.5 §8:
// CloudKit . ,
// ()
if !HealthSupport.deviceAvailable, quests.contains(where: \.isHealthQuest) { 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 }
// 1.5 §8:
// (nil ' ' UI )
if !HealthSupport.deviceAvailable, quests.contains(where: \.isHealthQuest) { return nil }
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved
return status
}
}