diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 883347a..33db63a 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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 등)은 판정 기준과 무관 — 건드리지 말 것 diff --git a/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift b/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift index 41c337b..51526bd 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift @@ -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: 가져오기 diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 6e29414..3ff6879 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -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.. 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) }