fix(goal,watch,diary): judge goals as of their end date + watch l10n + template render polish

전체 재분석에서 확정한 4건 + 양식 개선 2건:

1) 목표 자동 판정 기준 시점 (핵심 수정)
   기존에는 판정이 "실행되는 시점의 현재 주기" 기준이라, 종료일
   며칠 뒤에 앱을 열면 목표 기간 밖의 데이터로 달성/미달성이
   갈렸다. 이제 자동 판정(evaluateIfEnded)은 **종료일이 속한
   논리적 하루의 마지막 순간**(judgmentReference) 기준 — 늦게
   실행해도, 종료일을 과거로 고쳐도 종료일까지의 기록으로 판정.
   (하루 시작 시간 설정으로 기준이 미래가 되는 경우 now로 상한)
   수동 종료는 종료일 없는 목표 전용이므로 기존대로 "지금" 기준.
   목표 편집 시 종료일 변경 처리: 진행 중 목표는 저장 즉시 판정,
   완료된 목표의 종료일을 바꾸면 '진행 중'으로 재개 후 재판정
   (미래/없음 → 진행 중 복귀, 다른 과거 날짜 → 그 시점 기준 재판정).
   검증: -endGoalYesterday로 어제 종료 → 종료일 기준 '달성' 판정 확인.

2) 워치 컴플리케이션 지역화 2건
   - 다짐 달성률 rectangular의 기간 라벨("하루/주간/월간")이 String
     파라미터라 미추출 → en/ja에서 한국어 노출 → String(localized:)
   - 빈 상태 문구("목표 없음"/"다짐 없음") 동일 문제 → 추출 + 워치
     앱/워치 위젯 카탈로그에 en/ja 번역 추가 (타깃별 stringsdata로
     sync — 카탈로그 오염 없음, 전 카탈로그 missing 0)

3) 배포 체크리스트: CloudKit 프로덕션 스키마 배포 항목을 CLAUDE.md
   §1에 추가 (DiaryTemplate 등 새 레코드 타입은 출시 전 대시보드
   Deploy Schema to Production 필수)

4) 일기 양식 렌더 개선
   - 첫 렌더 비동기화: 저장된 썸네일을 플레이스홀더로 즉시 깔고
     본 렌더(2×)를 백그라운드로 — 복잡한 벡터 PDF도 페이지 넘김이
     걸리지 않음
   - 비활성 페이지 고해상도 강등: 화면 밖 페이지는 래스터를 2×로
     상한(allowsHighResolution) — TabView가 이웃 페이지를 유지해도
     양식 여러 장의 메모리 사용을 절약, 활성 복귀 시 원래 배율 복원
   검증: 양식 페이지 2× 프로그램 줌에서 점 노트 선명(회귀 없음),
   시드 필기 페이지 획 유지 확인.

빌드: Debug·Store·워치 스킴 모두 성공. 도움말(목표 판정 문구
갱신 — 종료일 기준·재판정 규칙 안내)과 CLAUDE.md §4.3 갱신,
문구 수정 stale 1건 삭제 및 ko/en/ja 번역 완료.

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-14 19:44:02 +09:00
parent bdb97e3c9f
commit f70e6a1c06
10 changed files with 166 additions and 38 deletions

View File

@ -16,7 +16,7 @@
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 5,000원) / `.yearly`(년 50,000원) / `.lifetime`(80,000원) — 임시 가격, App Store Connect 등록은 아직 안 됨 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. **코드 측 잔여 2건**: ① 결제 화면(PremiumView)에 개인정보처리방침·이용약관 링크(심사 3.1.2 필수 — URL 준비되면 추가), ② `IOS/Info.plist`(파일 있음)에 `ITSAppUsesNonExemptEncryption=NO` 추가 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), **CloudKit 프로덕션 스키마 배포**(대시보드 Deploy Schema to Production — DiaryTemplate 등 새 레코드 타입/필드는 개발 환경에만 자동 생성되므로 출시 전 필수), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. **코드 측 잔여 2건**: ① 결제 화면(PremiumView)에 개인정보처리방침·이용약관 링크(심사 3.1.2 필수 — URL 준비되면 추가), ② `IOS/Info.plist`(파일 있음)에 `ITSAppUsesNonExemptEncryption=NO` 추가 |
---
@ -113,6 +113,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와 동일**
- **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference`) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일
- 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것

View File

@ -313,6 +313,22 @@
}
}
},
"다짐 없음": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No quest"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クエストなし"
}
}
}
},
"목표": {
"localizations": {
"en": {
@ -345,6 +361,22 @@
}
}
},
"목표 없음": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No goal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標なし"
}
}
}
},
"새로고침": {
"localizations": {
"en": {

View File

@ -179,6 +179,22 @@
}
}
},
"다짐 없음": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No quest"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クエストなし"
}
}
}
},
"목표": {
"localizations": {
"en": {
@ -211,6 +227,22 @@
}
}
},
"목표 없음": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No goal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標なし"
}
}
}
},
"새로고침": {
"localizations": {
"en": {

View File

@ -135,7 +135,7 @@ struct GoalRateComplicationView: View {
.gaugeStyle(.accessoryCircular)
}
} else {
ComplicationEmptyView(message: "목표 없음")
ComplicationEmptyView(message: String(localized: "목표 없음"))
}
}
.containerBackground(.clear, for: .widget)
@ -266,16 +266,17 @@ struct QuestRateComplicationView: View {
}
}
HStack(spacing: 8) {
spanGauge(quest, raw: "day", label: "하루")
spanGauge(quest, raw: "week", label: "주간")
spanGauge(quest, raw: "month", label: "월간")
// String String(localized:)
spanGauge(quest, raw: "day", label: String(localized: "하루"))
spanGauge(quest, raw: "week", label: String(localized: "주간"))
spanGauge(quest, raw: "month", label: String(localized: "월간"))
}
}
default:
circular(quest)
}
} else {
ComplicationEmptyView(message: "다짐 없음")
ComplicationEmptyView(message: String(localized: "다짐 없음"))
}
}
.containerBackground(.clear, for: .widget)

View File

@ -1284,12 +1284,6 @@
},
"%@ 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 %lld개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 %2$lld개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -1301,6 +1295,12 @@
"state": "translated",
"value": "「%1$@」のテンプレートを削除しますか?このテンプレートを使う%2$lld枚のートページは白紙のキャンバスとして表示されます。手書きや要素はそのまま残ります"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$@ 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 %2$lld개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)"
}
}
}
},
@ -8464,18 +8464,18 @@
}
}
},
"종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요.": {
"종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고(앱을 며칠 뒤에 열어도 종료일까지의 기록 기준이라 결과가 달라지지 않아요), 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표의 종료일을 수정하면 다시 판정돼요 — 미래로 옮기면 진행 중으로 돌아와요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Goals with an end date are judged automatically once the date passes, and goals without one can be finished with the manual finish button. A goal counts as achieved when the quests' average completion rate meets the 'Achievement Threshold' you set when creating it (default 100%) — for example, with an 80% threshold it still counts as achieved even if slightly short. A goal whose end date passes with no quests switches to a 'Did you achieve it?' state for you to decide. Finished goals live in the archive at the bottom of the list."
"value": "Goals with an end date are judged automatically once the date passes (the result is based on records up to the end date, so opening the app days later doesn't change it). Goals without an end date can be finished with the manual finish button. A goal counts as achieved when the average completion of its quests meets the \"achievement threshold\" you set when creating it (100% by default) — for example, with an 80% threshold it still counts even if you fall slightly short. A goal whose end date passes without any quests becomes \"Did you achieve it?\" for you to decide. If you edit a finished goal's end date it is judged again — moving it to the future returns it to in progress. Finished goals are collected in the archive at the bottom of the list."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "終了日のある目標は日付が過ぎると自動で達成判定され、終了日のない目標は手動終了ボタンで終えられます。判定は、目標作成時に決めた「達成判定の基準」(初期値100%)以上にクエストの平均達成率が達していれば達成です — 例えば基準を80%にすると、少し届かなくても達成として記録されます。クエストがないまま終了日が過ぎた目標は「達成しましたか?」の状態になり、自分で選択します。完了した目標はリスト下部の完了アーカイブで確認できます。"
"value": "終了日のある目標は日付が過ぎると自動で達成判定されます(終了日までの記録が基準なので数日後にアプリを開いても結果は変わりません)。終了日のない目標は手動終了ボタンで終えられます。判定は、クエストの平均達成率が目標作成時に決めた「達成判定基準」初期値100%)以上なら達成です — たとえば基準を80%にすると、少し足りなくても達成として記録されます。クエストがないまま終了日を過ぎた目標は「達成しましたか?」の状態になり、自分で選択します。完了した目標の終了日を修正すると再判定され、未来に動かすと進行中に戻ります。完了した目標はリスト下部の完了アーカイブで見られます。"
}
}
}

View File

@ -178,7 +178,8 @@ struct DiaryNotePageView: View {
// (PDF/) ·
if let template {
DiaryTemplateBackground(template: template, pageIndex: page.templatePageIndex)
DiaryTemplateBackground(template: template, pageIndex: page.templatePageIndex,
isActive: isActive)
.allowsHitTesting(false)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}

View File

@ -123,34 +123,68 @@ enum DiaryTemplateRenderer {
/// . PDF
/// ( ).
///
/// PDF .
/// .
final class DiaryTemplateImageView: UIImageView {
/// · (SwiftUI )
var renderKey = ""
/// PDF (nil = , )
var pdfData: Data?
var pdfPageIndex = 0
private var pdfData: Data?
private var pdfPageIndex = 0
private var renderedScale: CGFloat = 0
private var renderTask: Task<Void, Never>?
/// DiaryZoomScrollView ( × , 3)
var targetContentsScale: CGFloat = 0 {
didSet { renderIfNeeded() }
}
/// () TabView
/// , 2× .
var allowsHighResolution = true {
didSet {
guard pdfData != nil, targetContentsScale > 0,
abs(targetContentsScale - renderedScale) > 0.01 else { return }
scheduleRender()
guard oldValue != allowsHighResolution else { return }
renderIfNeeded()
}
}
/// PDF + ()
func setPDF(data: Data, pageIndex: Int, placeholder: UIImage?) {
pdfData = data
pdfPageIndex = pageIndex
renderedScale = 0
image = placeholder
if targetContentsScale <= 0 { targetContentsScale = 2 }
renderIfNeeded()
}
/// ( )
func setImage(data: Data) {
pdfData = nil
renderedScale = 0
image = UIImage(data: data)
}
/// 2× ( )
private var effectiveScale: CGFloat {
allowsHighResolution ? targetContentsScale : min(targetContentsScale, 2)
}
private func renderIfNeeded() {
guard pdfData != nil, effectiveScale > 0,
abs(effectiveScale - renderedScale) > 0.01 else { return }
scheduleRender()
}
private func scheduleRender() {
guard let data = pdfData else { return }
let scale = targetContentsScale
let scale = effectiveScale
let index = pdfPageIndex
renderedScale = scale
renderTask?.cancel()
renderTask = Task { [weak self] in
// CoreGraphics PDF
// CoreGraphics PDF ·
let image = await Task.detached(priority: .userInitiated) {
DiaryTemplateRenderer.renderPDFPage(data: data, pageIndex: index, scale: scale)
}.value
@ -164,6 +198,8 @@ final class DiaryTemplateImageView: UIImageView {
struct DiaryTemplateBackground: UIViewRepresentable {
let template: DiaryTemplate
let pageIndex: Int
/// ( )
var isActive = true
func makeUIView(context: Context) -> DiaryTemplateImageView {
let view = DiaryTemplateImageView()
@ -177,20 +213,19 @@ struct DiaryTemplateBackground: UIViewRepresentable {
}
private func configure(_ view: DiaryTemplateImageView) {
view.allowsHighResolution = isActive
let key = "\(template.uuid.uuidString)-\(pageIndex)"
guard view.renderKey != key else { return }
view.renderKey = key
switch template.kind {
case .pdf:
view.pdfData = template.data
view.pdfPageIndex = pageIndex
// 2×
view.image = DiaryTemplateRenderer.renderPDFPage(
data: template.data, pageIndex: pageIndex, scale: 2
view.setPDF(
data: template.data,
pageIndex: pageIndex,
placeholder: template.thumbnailData.flatMap(UIImage.init(data:))
)
case .image:
view.pdfData = nil
view.image = UIImage(data: template.data)
view.setImage(data: template.data)
}
}
}

View File

@ -685,12 +685,23 @@ struct GoalEditorView: View {
private func save() {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if let goal {
let previousEndDate = goal.endDate
goal.title = trimmed
goal.symbolName = symbolName
goal.colorHex = color.hexString
goal.startDate = startDate
goal.endDate = hasEndDate ? endDate : nil
goal.achieveThresholdPercent = achieveThreshold
if goal.status == .inProgress {
//
// ( evaluateIfEnded )
goal.evaluateIfEnded()
} else if previousEndDate != goal.endDate {
// :
// / ' ' ,
goal.status = .inProgress
goal.evaluateIfEnded()
}
} else {
let newGoal = Goal(
title: trimmed,

View File

@ -135,7 +135,7 @@ struct HelpView: View {
HelpTopic(
symbol: "checkmark.seal.fill",
title: "목표 종료와 판정",
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고(앱을 며칠 뒤에 열어도 종료일까지의 기록 기준이라 결과가 달라지지 않아요), 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표의 종료일을 수정하면 다시 판정돼요 — 미래로 옮기면 진행 중으로 돌아와요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
),
HelpTopic(
symbol: "pin.fill",

View File

@ -408,14 +408,29 @@ extension Goal {
questAchievementRatio(math: math, now: now) * 100 >= Double(achieveThresholdPercent) - 0.0001
}
/// .
/// ( 100%)
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved
/// : ** **.
/// (now) ,
/// ( ) .
/// now .
private func judgmentReference(math: DayMath, now: Date) -> Date {
guard let endDate else { return now }
let cal = math.calendar
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: endDate) ?? endDate
let dayEnd = math.dayRange(forKey: math.dayKey(for: noon)).upperBound.addingTimeInterval(-1)
return min(dayEnd, now)
}
/// : ( nil UI )
/// .
/// ( 100%)
/// , .
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
status = meetsAchieveThreshold(math: math, now: judgmentReference(math: math, now: now))
? .achieved : .notAchieved
}
/// : ( nil UI ).
/// ""( ) .
func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? {
guard !quests.isEmpty else { return nil }
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved