feat(goal): configurable achievement threshold per goal
목표 종료 판정이 '다짐 전체 달성'으로 고정되어 있던 것을, 목표를 만들 때 달성 판정 기준(%)을 정할 수 있게 확장. 다짐들의 평균 달성률(달성=100%, 미달성=현재 주기 진행률)이 기준 이상이면 달성 완료로 판정한다. - Goal.achieveThresholdPercent (기본 100 = 기존 동작 유지, CloudKit 호환) - evaluateIfEnded/manualFinish가 questAchievementRatio ≥ 기준으로 판정 - 목표 추가/수정 화면에 '달성 판정 기준' 슬라이더 (10~100%, 5% 단위) - 수동 종료 안내 문구에 기준이 100% 미만이면 해당 기준 표기 - 검증용 런치 인자 -goalShowEditor 추가 - 그 외 진행률 표시·연산은 변경 없음 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
5f9593923a
commit
85a98c1ab0
@ -15,7 +15,13 @@ struct GoalListView: View {
|
||||
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
||||
private let premium = PremiumManager.shared
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showingAdd: Bool = {
|
||||
#if DEBUG
|
||||
// 검증용: -goalShowEditor YES → 목표 추가 화면 바로 표시
|
||||
if UserDefaults.standard.bool(forKey: "goalShowEditor") { return true }
|
||||
#endif
|
||||
return false
|
||||
}()
|
||||
@State private var showLimitAlert = false
|
||||
@State private var showingPremiumSheet = false
|
||||
@State private var isReordering = false
|
||||
@ -459,7 +465,11 @@ struct GoalDetailView: View {
|
||||
}
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
} footer: {
|
||||
Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.")
|
||||
if goal.achieveThresholdPercent < 100 {
|
||||
Text("종료 시 다짐 평균 달성률이 \(goal.achieveThresholdPercent)% 이상이면 달성으로 판정돼요.")
|
||||
} else {
|
||||
Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
@ -559,6 +569,7 @@ struct GoalEditorView: View {
|
||||
@State private var startDate: Date = .now
|
||||
@State private var hasEndDate = false
|
||||
@State private var endDate: Date = .now
|
||||
@State private var achieveThreshold = 100
|
||||
@State private var showingSymbolPicker = false
|
||||
|
||||
var body: some View {
|
||||
@ -601,6 +612,27 @@ struct GoalEditorView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Text("다짐 평균 달성률")
|
||||
Spacer()
|
||||
Text(verbatim: "\(achieveThreshold)%")
|
||||
.font(.body.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(achieveThreshold == 100 ? Color.secondary : AppTheme.green)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(achieveThreshold) },
|
||||
set: { achieveThreshold = Int($0.rounded()) }
|
||||
),
|
||||
in: 10...100,
|
||||
step: 5
|
||||
)
|
||||
} header: {
|
||||
Text("달성 판정 기준")
|
||||
} footer: {
|
||||
Text("목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'로 판정해요. 100%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(goal == nil ? String(localized: "목표 추가") : String(localized: "목표 수정"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -626,6 +658,7 @@ struct GoalEditorView: View {
|
||||
symbolName = goal.symbolName
|
||||
color = goal.color
|
||||
startDate = goal.startDate
|
||||
achieveThreshold = goal.achieveThresholdPercent
|
||||
if let end = goal.endDate {
|
||||
hasEndDate = true
|
||||
endDate = end
|
||||
@ -640,6 +673,7 @@ struct GoalEditorView: View {
|
||||
goal.colorHex = color.hexString
|
||||
goal.startDate = startDate
|
||||
goal.endDate = hasEndDate ? endDate : nil
|
||||
goal.achieveThresholdPercent = achieveThreshold
|
||||
} else {
|
||||
let newGoal = Goal(
|
||||
title: trimmed,
|
||||
@ -648,6 +682,7 @@ struct GoalEditorView: View {
|
||||
startDate: startDate,
|
||||
endDate: hasEndDate ? endDate : nil
|
||||
)
|
||||
newGoal.achieveThresholdPercent = achieveThreshold
|
||||
// 새 목표는 리스트 맨 뒤에 배치
|
||||
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
|
||||
newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1
|
||||
|
||||
@ -229,6 +229,9 @@ final class Goal {
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date?
|
||||
var statusRaw: String = GoalStatus.inProgress.rawValue
|
||||
/// 달성 판정 기준(%). 목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'.
|
||||
/// 100이면 기존과 동일하게 전체 달성이어야 함 (기본값 — CloudKit 요건).
|
||||
var achieveThresholdPercent: Int = 100
|
||||
/// [레거시] 접힘 상태는 기기별 로컬 설정(LocalPrefs.collapsedGoals)으로 이전.
|
||||
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
||||
var isCollapsed: Bool = false
|
||||
|
||||
@ -241,23 +241,36 @@ extension Goal {
|
||||
return sum / Double(counted.count)
|
||||
}
|
||||
|
||||
/// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신.
|
||||
/// (다짐 전체가 마지막 주기 기준으로 달성 상태이면 달성으로 처리)
|
||||
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
|
||||
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
|
||||
let allAchieved = quests.allSatisfy { quest in
|
||||
QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true
|
||||
/// 종료 판정용 다짐 평균 달성률 (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))
|
||||
}
|
||||
status = allAchieved ? .achieved : .notAchieved
|
||||
return sum / Double(quests.count)
|
||||
}
|
||||
|
||||
/// 수동 종료: 하위 다짐 전체 달성 여부로 판정 (다짐 없으면 nil 반환 → UI에서 직접 질문)
|
||||
/// 다짐 평균 달성률이 목표의 달성 기준(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 }
|
||||
let allAchieved = quests.allSatisfy { quest in
|
||||
QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true
|
||||
}
|
||||
status = allAchieved ? .achieved : .notAchieved
|
||||
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user