소킹 중 '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
679 lines
35 KiB
Swift
679 lines
35 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
|
||
}
|
||
|
||
/// 하루 단위 다짐의 "그날 집계 창". 마감 시각이 있으면 [하루 시작, 마감)으로 줄어든다.
|
||
/// 마감은 그 논리적 하루(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
|
||
}
|
||
}
|