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:
songyc macbook 2026-07-13 18:24:05 +09:00
parent 5f9593923a
commit 85a98c1ab0
3 changed files with 65 additions and 14 deletions

View File

@ -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

View File

@ -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

View File

@ -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
}
}