다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo): - 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수, '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일) - 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달 - 특정 기간 다짐: 반복이 없어 표시하지 않음 - 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고 건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김 - 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수), 소급 상한 400일 표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김): - 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑) - 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄 - 위젯 ④ 다짐 현황: 이름 아래 작은 글씨 - 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만 행 높이 14→19 (적응 채움이 자동 반영) - 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続) 문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은 달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와 주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함. 검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전 기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치), 위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인, missing: [], Debug·Store 스킴 빌드 성공. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
411 lines
17 KiB
Swift
411 lines
17 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
|
||
}
|
||
|
||
/// 해당 시점의 논리적 하루가 이 다짐의 수행일인지 (목표 하루 진행률의 모수 판정용).
|
||
/// 주/월 단위 다짐은 매일이 수행일이고, 특정 기간 다짐은 기간 안에 있을 때만 수행일.
|
||
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 }
|
||
return math.dayRange(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
|
||
)
|
||
}
|
||
|
||
// MARK: 하루/주간/월간 진행률 (CLAUDE.md §6.4 표시 구조)
|
||
|
||
/// span 구간에서의 값과, 주기 목표량을 span에 맞게 환산한 목표
|
||
func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult {
|
||
let range: Range<Date>
|
||
switch span {
|
||
case .day: range = math.dayRange(containing: now)
|
||
case .week: range = math.weekRange(containing: now)
|
||
case .month: range = math.monthRange(containing: 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%)로 판정되므로 캡 대상이 아니다(기존 로직 유지).
|
||
/// - 주/월/특정기간 단위 다짐: 기간 내 자유 누적이 정상 동작이라 캡 없이 전체 누적값을 쓴다.
|
||
private func spanValue(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
|
||
guard span != .day,
|
||
quest.period == .daily,
|
||
quest.direction == .atLeast,
|
||
quest.targetValue > 0
|
||
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 = math.dayRange(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 += min(dayValue, dailyTarget)
|
||
}
|
||
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] = [:]
|
||
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) {
|
||
values[segment.dayKey, default: 0]
|
||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||
}
|
||
}
|
||
case .count:
|
||
for entry in action.countEntries
|
||
where entry.timestamp >= lowerBound && entry.timestamp <= now {
|
||
values[math.dayKey(for: entry.timestamp), 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)
|
||
let lowerKey = cal.date(byAdding: .day, value: -Self.streakScanDays, to: todayKey)!
|
||
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
|
||
while range.lowerBound >= lowerBound {
|
||
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%).
|
||
/// 오늘이 수행일이 아닌 다짐(current가 nil)은 달성으로 취급 — 기존 allSatisfy의 `?? true`와 동일.
|
||
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).current(now: 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
|
||
}
|
||
|
||
/// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신.
|
||
/// (다짐 평균 달성률이 목표의 달성 기준 이상이면 달성으로 처리 — 기본 기준 100%)
|
||
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
|
||
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
|
||
status = meetsAchieveThreshold(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
|
||
}
|
||
}
|