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:
parent
d023f3eca1
commit
0aed7a9ec5
@ -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` |
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user