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:
parent
bdb97e3c9f
commit
f70e6a1c06
@ -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 등)은 판정 기준과 무관 — 건드리지 말 것
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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%にすると、少し足りなくても達成として記録されます。クエストがないまま終了日を過ぎた目標は「達成しましたか?」の状態になり、自分で選択します。完了した目標の終了日を修正すると再判定され、未来に動かすと進行中に戻ります。完了した目標はリスト下部の完了アーカイブで見られます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -135,7 +135,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "checkmark.seal.fill",
|
||||
title: "목표 종료와 판정",
|
||||
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
|
||||
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고(앱을 며칠 뒤에 열어도 종료일까지의 기록 기준이라 결과가 달라지지 않아요), 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표의 종료일을 수정하면 다시 판정돼요 — 미래로 옮기면 진행 중으로 돌아와요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "pin.fill",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user