mycode/myApp/HaruDanim/Shared/QuestProgress.swift
songyc macbook 6a486a4fbd feat(quest): daily quest deadline time — clip the day's measurement window
하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계):

- 모델: Quest.deadlineMinutes(-1=없음) 필드 1개 — CloudKit 안전(기본값), 주기가
  하루 단위가 아니면 값이 남아도 무시(hasDeadline이 주기·범위를 함께 검사)
- 의미론: 마감 = 그날 집계 창의 끝. dayMeasurementRange(forKey:) 단일 구현을
  주기 범위(current/judgment)·주/월 span 하루 루프(spanValue)·연속 버킷팅
  (perDayValues)·위젯 누적 라벨(spanRawValue)·일기 카드가 공유한다.
  원본 기록은 불변 — 마감 뒤 기록은 남되 그 다짐에만 미집계. 시간형은 기존
  겹침 클리핑이 마감을 걸친 세션을 부분 인정(추가 코드 0). '이하 유지'는
  마감까지 한도 안이면 그날 달성으로 고정.
- 경계 규칙: 마감은 그 논리적 하루(24h) 안에서 그 시계 시각의 등장 지점 —
  하루 시작보다 이른 시각은 다음 달력일 새벽, 시작과 같은 시각은 +24h(온전한
  하루)로 0길이 창이 수학적으로 불가능(에디터 검증 불필요, 오류 상태 없음)
- 의도된 최소화: '마감 지남' 전용 표시 없음(게이지 값이 마감 시점에 연속이라
  위젯 타임라인 마감 경계 불필요 — 위젯·워치·시리는 코드 변경 0으로 자동 일관),
  연속 달성은 값 클리핑만(오늘 놓친 끊김 반영은 내일부터 — 유예 규칙 유지),
  주간/월간/기간 마감은 미지원
- UI: 다짐 에디터 토글+시간 선택(하루 단위만, footer 설명), 주기 문구에
  "· 오전 8:00까지" 표기(목표 탭·시리 요약·CSV 자동 반영), 일기 카드
  "하루 목표 · 오전 8:00까지", 도움말 항목 추가, ko/en/ja 번역
- 검증: seedDemo에 마감 다짐 시드(창 안/밖/어제 실패 케이스) — 하루 100%
  (창 밖 기록 포함 시 200%가 되는 함정 통과), 주간 29%(=2/7)·월간 6%(=2/31)
  수기 계산과 정확 일치, streak-dump 연속 1일(어제 실패로 끊김) 확인,
  마감 없는 다짐 전부 기존 수치 유지(경로가 hasDeadline 가드로 완전 동일),
  일기 카드 1회/1회(원본 합계는 그대로), 영어 로케일 "By 8:00 AM" 렌더,
  Debug·Store 빌드 성공, 카탈로그 9종 missing/stale 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-15 17:52:18 +09:00

534 lines
25 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
func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult {
let range = spanRange(span, now: now)
return QuestProgressResult(
value: spanValue(for: span, range: range, now: now),
target: scaledTarget(for: span, range: range, now: now),
direction: quest.direction
)
}
/// span .
/// · ' ' ** ()** .
/// .
/// (: 5 10 5 )
/// · .
/// - span: 200% .
/// - ' '(atMost): (100%/0%) ( ).
/// - // : .
/// - (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 :
// - ' ' ( )
// - ()
// (// , ' ') .
let caps = quest.period == .daily && quest.direction == .atLeast && quest.targetValue > 0
guard caps || (quest.period == .daily && quest.hasDeadline) else {
return value(in: range, now: now)
}
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
}
/// 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) {
$0 + QuestProgress(quest: $1, math: math).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
}
}