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:
parent
f70e6a1c06
commit
fd4e198fe4
@ -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 등)은 판정 기준과 무관 — 건드리지 말 것
|
||||
|
||||
|
||||
@ -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: 가져오기
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user