mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 0e6804b531 feat(progress): 주기 몫 채운 주간·월간 다짐의 하루 진행률을 '이번 주/달 달성' 상태로 표시
- 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 채우면 하루 게이지가 0% 대신
  완료 상태(게이지 가득+초록 ✓+문구)로 표시 — 목표 탭 행·모음 카드·위젯 ②(체크)·
  ③(문구)·④(링 가득)·시리 하루 응답 공유
- 목표 하루 평균(combinedSpanRatio)에도 해당 다짐을 1.0으로 반영 —
  위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 헤더 자동 반영
- 판정은 QuestProgress.isPeriodFulfilled 단일 지점 — 기준일의 논리적 하루 끝
  클리핑으로 일기 과거 날짜 소급 차단. spanProgress 수치·판정(judgment)·연속(streak)
  불변(표시 계층 전용). 하루 다짐·특정 기간·atMost는 비대상, 잠금화면·워치
  컴플리케이션은 공간 제약으로 수치 유지
- 도움말 '진행률 읽는 법' 문구 갱신, l10n en/ja 완료(전 카탈로그 stale 0·missing 0)
- 검증: -progressSelfTest 37→49건 ALL PASS(기존 37건 수치 무변경), 신규
  -seedPeriodFulfilled 시드로 ko/en/ja·위젯 실렌더 확인, 시리 스모크 10건 OK,
  Debug/Store 빌드 성공(워치 타깃은 Shared 미포함·무변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-08-01 06:45:01 +09:00

588 lines
29 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
}
}
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
)
}
/// : ( ) .
/// 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
let agg = Aggregator(math: math)
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
let dayRange = dayMeasurementRange(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 += 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] {
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 }
// ( )
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) { 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 }
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 }
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved
return status
}
}