fix(goal): evaluate ended goals at launch and foreground, not only on goal tab

종료일이 지난 목표의 자동 판정(evaluateIfEnded)이 목표 탭 진입 시에만
실행돼, 목표 탭을 열지 않으면 위젯·워치·모음 탭 카드에 '진행 중'으로
계속 남아 있었다. 앱 실행/포그라운드 복귀(scenePhase .active) 시점에
같은 판정을 추가로 실행한다.

- 목표 탭 onAppear 로직과 동일한 코드 — 진행 중 목표만 조회, 변경이
  있을 때만 DataChange.commit (위젯·워치·라이브 액티비티 갱신)
- 다짐 없는 목표는 evaluateIfEnded가 건드리지 않으므로 '확인 필요'
  직접 선택 흐름은 그대로
- 위젯 확장에서는 판정하지 않음 (프로바이더 경합·CloudKit 내보내기
  지연 위험 회피)
- 검증용 DEBUG 인자 -endGoalYesterday "제목" 추가. 시뮬레이터에서
  목표 탭을 열지 않은 콜드 스타트만으로 statusRaw가 achieved로
  전환되는 것을 스토어 조회로 확인

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 19:37:43 +09:00
parent d023f3eca1
commit 0aed7a9ec5
4 changed files with 36 additions and 2 deletions

View File

@ -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 <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) |
| 버블 | `-radialExpanded` |

View File

@ -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") {

View File

@ -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<Goal>(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>` : N ( )
static func pinGoalsIfRequested(context: ModelContext) {
let n = UserDefaults.standard.integer(forKey: "pinGoals")

View File

@ -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<Goal>(
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) }
}
}