From 85a98c1ab0f43e3049e823bdf1b25d250d2653de Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 18:24:05 +0900 Subject: [PATCH] feat(goal): configurable achievement threshold per goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목표 종료 판정이 '다짐 전체 달성'으로 고정되어 있던 것을, 목표를 만들 때 달성 판정 기준(%)을 정할 수 있게 확장. 다짐들의 평균 달성률(달성=100%, 미달성=현재 주기 진행률)이 기준 이상이면 달성 완료로 판정한다. - Goal.achieveThresholdPercent (기본 100 = 기존 동작 유지, CloudKit 호환) - evaluateIfEnded/manualFinish가 questAchievementRatio ≥ 기준으로 판정 - 목표 추가/수정 화면에 '달성 판정 기준' 슬라이더 (10~100%, 5% 단위) - 수동 종료 안내 문구에 기준이 100% 미만이면 해당 기준 표기 - 검증용 런치 인자 -goalShowEditor 추가 - 그 외 진행률 표시·연산은 변경 없음 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/Views/GoalViews.swift | 39 ++++++++++++++++++++-- myApp/HaruDanim/Shared/Models.swift | 3 ++ myApp/HaruDanim/Shared/QuestProgress.swift | 37 +++++++++++++------- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index de3375d..2169772 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -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())) ?? [] newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 diff --git a/myApp/HaruDanim/Shared/Models.swift b/myApp/HaruDanim/Shared/Models.swift index ff0de5f..b477f75 100644 --- a/myApp/HaruDanim/Shared/Models.swift +++ b/myApp/HaruDanim/Shared/Models.swift @@ -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 diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 20458fd..8f8f888 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -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 } }