From 0aed7a9ec5a06cc453ac2faa4b06ac27709e0e1c Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 19:37:43 +0900 Subject: [PATCH] fix(goal): evaluate ended goals at launch and foreground, not only on goal tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종료일이 지난 목표의 자동 판정(evaluateIfEnded)이 목표 탭 진입 시에만 실행돼, 목표 탭을 열지 않으면 위젯·워치·모음 탭 카드에 '진행 중'으로 계속 남아 있었다. 앱 실행/포그라운드 복귀(scenePhase .active) 시점에 같은 판정을 추가로 실행한다. - 목표 탭 onAppear 로직과 동일한 코드 — 진행 중 목표만 조회, 변경이 있을 때만 DataChange.commit (위젯·워치·라이브 액티비티 갱신) - 다짐 없는 목표는 evaluateIfEnded가 건드리지 않으므로 '확인 필요' 직접 선택 흐름은 그대로 - 위젯 확장에서는 판정하지 않음 (프로바이더 경합·CloudKit 내보내기 지연 위험 회피) - 검증용 DEBUG 인자 -endGoalYesterday "제목" 추가. 시뮬레이터에서 목표 탭을 열지 않은 콜드 스타트만으로 statusRaw가 achieved로 전환되는 것을 스토어 조회로 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/CLAUDE.md | 4 ++-- myApp/HaruDanim/IOS/ContentView.swift | 1 + myApp/HaruDanim/IOS/Core/DebugSeed.swift | 10 ++++++++++ myApp/HaruDanim/IOS/Haru_DanimApp.swift | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index fa57e11..a584a4c 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -107,7 +107,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}" ### 4.3 목표 진행률·판정 (Goal extension) - `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유 -- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded`, 목표 탭 진입 시 실행) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` − 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일** +- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded` — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` − 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일** - 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일 - 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것 @@ -284,7 +284,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}" | 영역 | 인자 | |---|---| | 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` | -| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` | +| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) | | 버블 | `-radialExpanded` | diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index edda0ef..a969592 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -44,6 +44,7 @@ struct ContentView: View { DebugSeed.seedIfRequested(context: context) DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) + DebugSeed.endGoalYesterdayIfRequested(context: context) // 검증용: -themeAutoToggle YES → 3초 후 설정 화면과 같은 경로(App Group defaults)로 // 테마를 반전시켜 '재시작 없이 즉시 적용'되는지 확인 if UserDefaults.standard.bool(forKey: "themeAutoToggle") { diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 09c498f..fa36b3e 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -21,6 +21,16 @@ enum DebugSeed { LiveActivityManager.sync(context: context) } + /// `-endGoalYesterday <목표 제목>` 런치 인자: 해당 목표의 종료일을 어제로 변경 + /// (목표 탭을 열지 않아도 실행 시점에 자동 판정되는지 검증용) + static func endGoalYesterdayIfRequested(context: ModelContext) { + guard let title = UserDefaults.standard.string(forKey: "endGoalYesterday") else { return } + let descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) + guard let goal = try? context.fetch(descriptor).first else { return } + goal.endDate = Calendar.current.date(byAdding: .day, value: -1, to: .now) + try? context.save() + } + /// `-pinGoals ` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용) static func pinGoalsIfRequested(context: ModelContext) { let n = UserDefaults.standard.integer(forKey: "pinGoals") diff --git a/myApp/HaruDanim/IOS/Haru_DanimApp.swift b/myApp/HaruDanim/IOS/Haru_DanimApp.swift index 8fb7ddf..7f1a122 100644 --- a/myApp/HaruDanim/IOS/Haru_DanimApp.swift +++ b/myApp/HaruDanim/IOS/Haru_DanimApp.swift @@ -42,10 +42,33 @@ struct Haru_DanimApp: App { // 앱 사용 중 변경된 데이터를 워치에 반영 (활성/백그라운드 전환 시점) WatchSyncManager.shared.pushSnapshot() if newPhase == .active { + // 종료일이 지난 목표 판정 — 목표 탭을 열지 않아도 + // 위젯·워치·모음 탭이 오래 '진행 중'으로 남지 않게 한다 + Self.evaluateEndedGoals(context: container.mainContext) // 백그라운드(워치 명령)에서 시작이 거부됐던 Live Activity 재시도 + // 앱이 꺼진 사이 워치에서 바뀐 세션 상태를 다이나믹 아일랜드에 반영 LiveActivityManager.sync(context: container.mainContext) } } } + + /// 종료일이 지났고 다짐이 있는 진행 중 목표의 달성 여부를 판정해 상태를 갱신. + /// 목표 탭 진입 시(GoalListView.onAppear) 로직과 동일 — 실행/포그라운드 복귀로 시점만 추가. + /// 다짐 없는 목표는 evaluateIfEnded가 건드리지 않으므로 '확인 필요' 흐름은 그대로다. + @MainActor + private static func evaluateEndedGoals(context: ModelContext) { + let inProgress = GoalStatus.inProgress.rawValue + let goals = (try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.statusRaw == inProgress } + ))) ?? [] + guard !goals.isEmpty else { return } + let math = DayMath() + var changed = false + for goal in goals { + let before = goal.status + goal.evaluateIfEnded(math: math) + if goal.status != before { changed = true } + } + if changed { DataChange.commit(context: context) } + } }