fix(goal,diary): pro-rated mid-period judgment, day-key edge, template render retries

재분석에서 나온 잔여 항목 일괄 처리:

1) 판정 하루 키 엣지 (A)
   judgmentReference가 종료일 '정오'를 경유해 논리적 하루를 찾던
   것을 달력일 키 직접 조회로 변경 — '하루 시작 시간'을 12시 이후로
   설정한 경우에도 판정 범위가 전날로 밀리지 않는다.

2) 주기 중간 종료 판정 (B — 설계 결정: 경과일 비례)
   종료 판정 전용 QuestProgress.judgment(asOf:) 신설, 판정 평균
   (questAchievementRatio)이 current() 대신 사용:
   - 값은 기준 시점까지의 누적으로 제한 — 종료일 이후 같은 주기의
     기록이 판정에 섞이지 않음
   - 주간/월간/특정 기간 다짐의 목표량·한도를 주기 내 경과일 비율로
     축소 — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로
     깎이지 않고, '이하 유지' 한도도 같은 비율이라 대칭적으로 공정
   - 하루 단위 다짐은 축소 없음 (그날 목표는 온전히 채워야 달성)
   - 수동 종료(manualFinish)도 같은 함수를 타므로 주중 수동 종료의
     주간 다짐이 공정하게 판정됨. 표시 연산(combinedSpanRatio·
     spanProgress)은 불변.
   검증: 주간 2시간 다짐 목표를 주 시작일(월)에 종료 →
   화요일 기록 40분이 배제되고 비례 목표(≈17분) 기준으로 판정됨을
   DB에서 확인 (처음 실패는 '설치 직후 첫 실행 인자 무시' 시뮬레이터
   특성이 원인 — 코드 문제 아님, §2.2 문서화된 사항).

3) 양식 렌더 트레이드오프 (C)
   - 백그라운드 렌더 실패 시 renderedScale을 되돌려 다음 기회(줌·
     재표시)에 재시도되게 함
   - 썸네일 겸 첫 표시 플레이스홀더 해상도 320→512px — 본 렌더
     도착 전의 흐릿함 완화 (저장 증가는 수십 KB 수준)
   - 양식 관리의 사용 중 페이지 수를 행마다 재스캔하지 않고 1회 집계

빌드: Debug·Store·워치 3스킴 성공. CLAUDE.md §4.3에 주기 중간
종료 판정 규칙 추가. 신규 사용자 문구 없음(카탈로그 변경 없음).

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 20:13:09 +09:00
parent f70e6a1c06
commit fd4e198fe4
3 changed files with 65 additions and 14 deletions

View File

@ -113,7 +113,8 @@ 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로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference` — 달력일을 키로 직접 찾아 '하루 시작 시간'이 오후여도 안전) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- **주기 중간 종료 판정**(`QuestProgress.judgment(asOf:)`): 판정용 진행률은 current()와 달리 ①값을 기준 시점까지의 누적으로 제한(종료일 이후 같은 주기의 기록 배제)하고 ②주간/월간/특정 기간 다짐의 목표량·한도를 **주기 내 경과일 비율로 축소** — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로 깎이지 않고, atMost 한도도 같은 비율이라 공정. 하루 단위 다짐은 목표량 축소 없음(그날 목표는 온전히 채워야 달성). 표시 연산(combinedSpanRatio·spanProgress)에는 영향 없음
- 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일
- 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것

View File

@ -98,17 +98,22 @@ enum DiaryTemplateRenderer {
return resized.jpegData(compressionQuality: 0.85)
}
/// ( , ~320px)
/// ( px).
/// () ,
/// 릿 (JPEG KB ).
static let thumbnailSide: CGFloat = 512
/// ( )
static func thumbnailData(kind: DiaryTemplateKind, data: Data) -> Data? {
switch kind {
case .pdf:
return renderPDFPage(data: data, pageIndex: 0, scale: 320 / DiaryPageMetrics.size.height)?
return renderPDFPage(data: data, pageIndex: 0, scale: thumbnailSide / DiaryPageMetrics.size.height)?
.jpegData(compressionQuality: 0.8)
case .image:
guard let image = UIImage(data: data) else { return nil }
let longest = max(image.size.width, image.size.height)
guard longest > 0 else { return nil }
let ratio = min(320 / longest, 1)
let ratio = min(thumbnailSide / longest, 1)
let target = CGSize(width: image.size.width * ratio, height: image.size.height * ratio)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
@ -188,7 +193,12 @@ final class DiaryTemplateImageView: UIImageView {
let image = await Task.detached(priority: .userInitiated) {
DiaryTemplateRenderer.renderPDFPage(data: data, pageIndex: index, scale: scale)
}.value
guard !Task.isCancelled, let image else { return }
guard !Task.isCancelled else { return }
guard let image else {
// ( ) (/)
self?.renderedScale = 0
return
}
self?.image = image
}
}
@ -527,7 +537,7 @@ struct DiaryTemplateManagerView: View {
HStack {
DiaryTemplateRowLabel(template: template)
Spacer()
let usage = usageCount(template)
let usage = usageCounts[template.uuid.uuidString] ?? 0
if usage > 0 {
Text("페이지 \(usage)개 사용 중")
.font(.caption2)
@ -546,10 +556,18 @@ struct DiaryTemplateManagerView: View {
}
}
///
private var usageCounts: [String: Int] {
var counts: [String: Int] = [:]
for page in allPages where !page.templateID.isEmpty {
counts[page.templateID, default: 0] += 1
}
return counts
}
/// ( )
private func usageCount(_ template: DiaryTemplate) -> Int {
let id = template.uuid.uuidString
return allPages.filter { $0.templateID == id }.count
usageCounts[template.uuid.uuidString] ?? 0
}
// MARK:

View File

@ -144,6 +144,36 @@ struct QuestProgress {
)
}
/// : ( ) .
/// current() :
/// 1)
/// 2) // ( )
/// " 4 " ,
/// ' '
/// (
/// ).
func judgment(asOf reference: Date) -> QuestProgressResult? {
guard let full = currentPeriodRange(now: reference) else { return nil }
let clipped = full.lowerBound..<min(full.upperBound, reference)
guard clipped.lowerBound < clipped.upperBound else { return nil }
let target: Double
switch quest.period {
case .daily:
target = quest.targetValue
case .weekly, .monthly, .custom:
let totalDays = math.dayKeys(in: full).count
let elapsedDays = math.dayKeys(in: clipped).count
target = totalDays > 0 && elapsedDays < totalDays
? quest.targetValue * Double(elapsedDays) / Double(totalDays)
: quest.targetValue
}
return QuestProgressResult(
value: value(in: clipped, now: reference),
target: target,
direction: quest.direction
)
}
// MARK: // (CLAUDE.md §6.4 )
/// span ( · )
@ -390,12 +420,13 @@ extension Goal {
}
/// (0...1).
/// 100%, (' ' 0%).
/// (current nil) allSatisfy `?? true` .
/// 100%, (' ' 0%).
/// (judgment nil) allSatisfy `?? true` .
/// · QuestProgress.judgment(asOf:) .
func questAchievementRatio(math: DayMath = DayMath(), now: Date = .now) -> Double {
guard !quests.isEmpty else { return 0 }
let sum = quests.reduce(0.0) { partial, quest in
guard let result = QuestProgress(quest: quest, math: math).current(now: now) else {
guard let result = QuestProgress(quest: quest, math: math).judgment(asOf: now) else {
return partial + 1
}
return partial + (result.isAchieved ? 1 : min(max(result.ratio, 0), 1))
@ -411,12 +442,13 @@ extension Goal {
/// : ** **.
/// (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)
let dayKey = math.calendar.startOfDay(for: endDate)
let dayEnd = math.dayRange(forKey: dayKey).upperBound.addingTimeInterval(-1)
return min(dayEnd, now)
}