fix(ui): quest-less goals show status text instead of 0% + Swift 6 warning cleanup

- 다짐 없는 목표의 진행률 0% 표기(실패로 오독)를 상태 문구로 교체 — 사용자 승인 방식 ③
  · 위젯 ② 목표 진행바: 0% 바 3줄 대신 "다짐이 없어요" (④와 동일 문구·기존 처리와 통일)
  · 일기 '이 날의 목표' 헤더: "다짐 없음" + 게이지 생략 (기존 캡션은 유지)
  · 모음 탭 카드(게이지 블록 미표시)·위젯 ④·시리(오류 응답)는 이미 처리돼 있어 무변경
  · 잠금화면·워치 컴플리케이션은 공간 제약으로 수치 유지 — '수행일 아님'과 같은 의도된 단순화(§4.3 문서화)
- Swift 6 대비 경고 정리(사용자 승인): DiaryTemplateRenderer.renderPDFPage·DiaryPageMetrics를
  nonisolated로 선언(순수 CoreGraphics — 백그라운드 렌더 호출부와 격리 일치), DiarySectionConfig
  마이그레이션 onAppear 클로저 교정 — iOS 앱 타깃 경고 0
- 검증 인자 -seedEmptyGoal 신설(다짐 없는 목표 시드, 기본 시드·마케팅 스크린샷과 분리)
- l10n 신규 2키 ko/en/ja(다짐 없음·시드 목표 이름), 카탈로그 클린. CLAUDE.md §4.3·§14 갱신
- 검증: 위젯 ② 프리뷰·일기 카드 시뮬 스크린샷, Debug/Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-18 00:49:36 +09:00
parent 623217b907
commit da515a8f71
8 changed files with 82 additions and 14 deletions

View File

@ -128,7 +128,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- **마감 시각(deadlineMinutes, 하루 단위 전용)**: 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(`dayMeasurementRange` — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). **의도된 트레이드오프**: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(`spanRawValue`). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요)
### 4.3 목표 진행률·판정 (Goal extension)
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유. **다짐이 하나도 없는 목표는 수치(0%) 대신 '다짐 없음' 상태로 표기** — 위젯 ②('다짐이 없어요' 문구, ④는 원래 처리돼 있었음)·일기 '이 날의 목표' 헤더('다짐 없음'+게이지 생략)·시리(기존 '아직 다짐이 없어요' 오류 응답). 모음 탭 카드는 다짐 없으면 게이지 블록 자체가 안 그려져 해당 없음. 잠금화면·워치 컴플리케이션은 공간 제약으로 수치(0%) 유지 — 수행일 아님과 동일한 의도된 단순화
- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded` — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일**
- **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference` — 달력일을 키로 직접 찾아 '하루 시작 시간'이 오후여도 안전) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- **주기 중간 종료 판정**(`QuestProgress.judgment(asOf:)`): 판정용 진행률은 current()와 달리 ①값을 기준 시점까지의 누적으로 제한(종료일 이후 같은 주기의 기록 배제)하고 ②주간/월간/특정 기간 다짐의 목표량·한도를 **주기 내 경과일 비율로 축소** — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로 깎이지 않고, atMost 한도도 같은 비율이라 공정. 하루 단위 다짐은 목표량 축소 없음(그날 목표는 온전히 채워야 달성). 표시 연산(combinedSpanRatio·spanProgress)에는 영향 없음
@ -324,7 +324,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)
공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. **시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것**, ko 기본 사용은 기존과 동일), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`(**인자가 있는 콜드 스타트 동안만 유효** — 다음 실행에 인자를 빼면 실권한으로 복귀하므로 프리미엄 화면 검증 시 매 런치에 함께 줄 것. 지속 강제는 설정의 DEBUG 테스트 토글), `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. **시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것**, ko 기본 사용은 기존과 동일), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-seedEmptyGoal YES`(다짐 없는 진행 중 목표 1개 추가 — '다짐 없음' 표기 검증용, 기본 시드와 분리), `-premium YES/NO`(**인자가 있는 콜드 스타트 동안만 유효** — 다음 실행에 인자를 빼면 실권한으로 복귀하므로 프리미엄 화면 검증 시 매 런치에 함께 줄 것. 지속 강제는 설정의 DEBUG 테스트 토글), `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
| 영역 | 인자 |
|---|---|

View File

@ -44,6 +44,7 @@ struct ContentView: View {
.task {
#if DEBUG
DebugSeed.seedIfRequested(context: context)
DebugSeed.seedEmptyGoalIfRequested(context: context)
DebugSeed.seedBulkIfRequested(context: context)
DebugSeed.autoStartIfRequested(context: context)
DebugSeed.pinGoalsIfRequested(context: context)

View File

@ -467,6 +467,19 @@ enum DebugSeed {
)
}
/// `-seedEmptyGoal YES`: 1
/// · ' ' ( · )
static func seedEmptyGoalIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedEmptyGoal") else { return }
let title = String(localized: "새해 다짐 정리하기")
let existing = (try? context.fetch(FetchDescriptor<Goal>()))?.contains { $0.title == title } ?? false
guard !existing else { return }
let goal = Goal(title: title, symbolName: "sparkles", colorHex: "#D9A621",
startDate: .now, endDate: nil)
context.insert(goal)
try? context.save()
}
static func seedIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0

View File

@ -3157,6 +3157,22 @@
}
}
},
"다짐 없음": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No quests"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クエストなし"
}
}
}
},
"다짐 점 표시": {
"localizations": {
"en": {
@ -5827,6 +5843,22 @@
}
}
},
"새해 다짐 정리하기": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Organize New Year resolutions"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新年の抱負を整理する"
}
}
}
},
"색": {
"localizations": {
"en": {

View File

@ -17,8 +17,9 @@ import SwiftData
import PencilKit
import PhotosUI
/// ( )
enum DiaryPageMetrics {
/// ( ).
/// PDF (DiaryTemplateRenderer) .
nonisolated enum DiaryPageMetrics {
static let size = CGSize(width: 768, height: 1024)
static let lineSpacing: CGFloat = 44
static let lineTopInset: CGFloat = 72

View File

@ -42,7 +42,9 @@ enum DiaryTemplateRenderer {
/// PDF (768×1024) aspect-fit .
/// PDF(A4 ) (·) .
/// scale = ( × , )
static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? {
/// CoreGraphics (Task.detached)
/// (Swift 6 ).
nonisolated static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? {
guard scale > 0,
let provider = CGDataProvider(data: data as CFData),
let document = CGPDFDocument(provider),

View File

@ -1196,7 +1196,7 @@ private struct DiarySummaryPage: View {
.padding(20)
}
}
.onAppear(perform: DiarySectionConfig.migrateIfNeeded)
.onAppear { DiarySectionConfig.migrateIfNeeded() }
.sheet(isPresented: $showingCalendarPicker) {
DiaryCalendarPickerSheet(entry: entry)
}
@ -1483,12 +1483,21 @@ private struct DiaryGoalsCard: View {
.font(.footnote.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 6)
Text("\(Int((dayRatio * 100).rounded()))%")
.font(.footnote.weight(.bold).monospacedDigit())
.foregroundStyle(goal.color)
if goal.sortedQuests.isEmpty {
// 0% " "
Text("다짐 없음")
.font(.caption.weight(.medium))
.foregroundStyle(.tertiary)
} else {
Text("\(Int((dayRatio * 100).rounded()))%")
.font(.footnote.weight(.bold).monospacedDigit())
.foregroundStyle(goal.color)
}
}
// ( )
if !goal.sortedQuests.isEmpty {
gaugeBar(ratio: dayRatio, color: goal.color)
}
// ( )
gaugeBar(ratio: dayRatio, color: goal.color)
if goal.sortedQuests.isEmpty {
Text("다짐이 아직 없는 목표예요")
.font(.caption2)

View File

@ -146,9 +146,19 @@ struct GoalBarsCellView: View {
.font(.caption.weight(.semibold))
.lineLimit(1)
}
SpanBarRow(label: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color)
SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color)
SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color)
if goal.quests.isEmpty {
// 0% " " ( )
Spacer()
Text("다짐이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Spacer()
} else {
SpanBarRow(label: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color)
SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color)
SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color)
}
}
.padding(fullBleed ? 14 : 10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)