fix(watch): list every goal & quest in complication recommendations

- 페이스 편집기에 3번째 목표부터(다짐은 첫 목표의 2개까지만) 안 뜨던 원인 = recommendations()의 prefix(2) 하드캡 — 워치는 컴플리케이션 파라미터 편집 UI가 없어 추천 목록이 곧 선택지의 전부라, 캡에 걸린 목표·다짐은 페이스에 추가할 방법 자체가 없었음 (보고된 13개 = 현재현황 1 + 목표 2×3기간 + 다짐 2×3기간과 정확히 일치)
- 목표·다짐 달성률 둘 다 상한 제거: 진행 중 목표 전부 × 3기간, 다짐 전부 × 3기간 나열
- 워치 앱이 스냅숏을 적용할 때 invalidateConfigurationRecommendations() 호출 추가 — 이후 아이폰에서 목표를 새로 만들어도 시스템 임의 재조회를 기다리지 않고 목록에 자동 반영
- CLAUDE.md §11에 "추천 목록 = 선택지 전부, 상한 금지" 규칙 명시
- 검증: 워치·Debug·Store 3스킴 빌드 성공, 워치 심 -complicationPreview 렌더 정상 (추천 목록 자체는 페이스 편집 UI라 헤드리스 확인 불가 — 실기기 확인 필요)

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-16 05:33:15 +09:00
parent fef25f30d0
commit 232e8c504d
3 changed files with 11 additions and 3 deletions

View File

@ -282,6 +282,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- 워치→iPhone: `sendMessage`(응답=최신 스냅숏), 실패/미활성 시 `transferUserInfo` 큐 폴백 (5분 지난 큐 명령은 무시)
- 스냅숏은 워치 App Group defaults에 캐시(오래된 스냅숏 역행 방지: generatedAt 비교) → 컴플리케이션이 읽음
- **컴플리케이션** 3종 (15분 .after 타임라인): 현재 현황(대표 행동+타이머+N) / 목표 달성률(기간 선택, 게이지) / 다짐 달성률(rectangular는 3기간 모두, **atMost는 게이지 대신 한도 안/초과 상태 표현**)
- **추천 목록(recommendations) = 선택지 전부**: 워치는 컴플리케이션 파라미터 편집 UI가 없어 페이스 편집기의 목록이 recommendations()가 돌려준 조합으로 한정된다 — 진행 중 **목표 전부 × 3기간, 다짐 전부 × 3기간**을 상한 없이 나열할 것(예전 prefix(2) 캡이 "3번째 목표부터 페이스에 추가 불가" 버그였음). 워치 앱이 스냅숏을 적용할 때 `invalidateConfigurationRecommendations()`를 호출해 목표·다짐 변경이 목록에 자동 반영된다
- **유령 타이머 안전장치**(현재 현황): '측정 중' 스냅숏이 `generatedAt` 기준 2시간을 넘기면 무한 타이머 대신 "새로고침 필요" 표시로 강등(`StatusProvider.runningTrustInterval`) — 종료 푸시가 유실돼도 유령 타이머가 몇 시간씩 남지 않음. 워치 앱을 열면 즉시 복구, 진짜 장시간 측정도 다음 스냅숏에 타이머 복귀(데이터 무영향)
- ⚠️ `recommendations()`의 description에 포맷 텍스트 금지 — `Text(verbatim:)`만 (WidgetKit assertion으로 익스텐션 즉사)

View File

@ -100,6 +100,9 @@ final class WatchStore: NSObject {
Self.groupDefaults.set(data, forKey: WatchSync.cachedSnapshotKey)
}
WidgetCenter.shared.reloadAllTimelines()
// ·
// ( )
WidgetCenter.shared.invalidateConfigurationRecommendations()
#if DEBUG
autoRunIfNeeded()
#endif

View File

@ -69,11 +69,14 @@ struct GoalRateProvider: AppIntentTimelineProvider {
}
/// ( × //)
/// UI ** **
///
/// ( prefix(2) "3 " ). .
/// : description ( ) WidgetKit assertion
/// .
/// Text(verbatim:) .
func recommendations() -> [AppIntentRecommendation<GoalRateConfigIntent>] {
let cached = (ComplicationStore.snapshot()?.goals ?? []).prefix(2)
let cached = (ComplicationStore.snapshot()?.goals ?? [])
.map { WatchGoalEntity(id: $0.id, title: $0.title) }
// ( )
let goals = cached.isEmpty
@ -212,14 +215,15 @@ struct QuestRateProvider: AppIntentTimelineProvider {
}
/// ( × //)
/// (GoalRate
/// prefix(2) " " ).
/// : description Text(verbatim:) (GoalRate )
func recommendations() -> [AppIntentRecommendation<QuestRateConfigIntent>] {
let cached = (ComplicationStore.snapshot()?.goals ?? [])
.flatMap { goal in goal.quests.map { WatchQuestEntity(id: $0.id, title: "\(goal.title) · \($0.name)") } }
.prefix(2)
let quests = cached.isEmpty
? [WatchQuestEntity(id: UUID(), title: String(localized: "다짐"))]
: Array(cached)
: cached
return quests.flatMap { quest in
WatchSpanOption.allCases.map { span in
let intent = QuestRateConfigIntent()