mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 2ae469d187 perf(1.5-p11): 등가 성능 최적화 — 건강 캐시 인메모리·백필 스위프·게이지 버킷화·위젯 타임라인, 빌드 9
소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변):
- HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거
- DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거)
- 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명)
- refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회)
- QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존)
- 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회)
- 일기 건강 카드 칩 value() 이중 호출 제거

검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10
ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경.
whats-new-1.5 3언어에 성능 개선 줄 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-26 14:23:56 +09:00

679 lines
35 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// 1.5(9) : value(in:) O(×)
// (perDayGaugeValues 1 , ) .
// ·
let daily = perDayGaugeValues(in: range, now: now)
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
let dayValue = daily[key] ?? 0
sum += caps ? min(dayValue, dailyTarget) : dayValue
}
return sum
}
/// span " value(in: dayMeasurementRange)"
/// ( · · now)
/// (1.5(9) O(×) O(+)).
/// perDayValues : now
/// (§6.4 ), " "
/// value(in:) (Aggregator) .
private func perDayGaugeValues(in range: Range<Date>, now: Date) -> [Date: Double] {
// : (
// dayMeasurementRange )
if let health = quest.healthTarget {
var values: [Date: Double] = [:]
for key in math.dayKeys(in: range) {
if let value = HealthQuestValues.dayValue(target: health, quest: quest, dayKey: key) {
values[key] = value
}
}
return values
}
return perDayRecordValues(in: range, now: now)
}
/// (·) perDayGaugeValues .
/// spanRawValue( ) : Aggregator
/// ( targetActions 0)
private func perDayRecordValues(in range: Range<Date>, now: Date) -> [Date: Double] {
var values: [Date: Double] = [:]
// perDayValues( )
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 {
// now
// (Aggregator.seconds `session.endAt ?? now` )
let end = session.endAt ?? now
let start = max(session.startAt, range.lowerBound)
let clippedEnd = min(end, range.upperBound)
guard start < clippedEnd else { continue }
for segment in math.splitByDay(start: start, end: clippedEnd) {
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 range.contains(entry.timestamp) {
let key = math.dayKey(for: entry.timestamp)
if clips, entry.timestamp >= windowEnd(key) { continue }
values[key, default: 0] += Double(entry.amount)
}
}
}
return values
}
/// 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) }
// 1.5(9) : Aggregator 1.
// (perDayRecordValues )
let daily = perDayRecordValues(in: range, now: now)
return math.dayKeys(in: range).reduce(0) { $0 + (daily[$1] ?? 0) }
}
// 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
}
}