fix(quest): cap prorated span display at 100% — weekly quest showed 350% daily

- 보고된 버그: 주 2회 다짐을 목요일에 1회 수행 → 목표 탭 하루 진행률 350% (1 ÷ 주목표 2/7). 주기와 다른 span의 환산 목표는 페이스 가이드일 뿐인데 초과분까지 퍼센트로 표기되던 왜곡
- 수정: spanProgress에서 '이상 달성'이면서 환산 목표 span일 때 값을 목표에서 캡 (usesScaledTarget 분기) — 자기 주기 span(하루 다짐의 '하루' 200% 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, '이하 유지'는 값 무캡(UI 초과 판정·페이스 경고 유지). 게이지(ratio)·달성 판정·연속·목표 종료 판정은 원래 캡·별도 경로라 무영향 — 바뀌는 것은 퍼센트 문구뿐
- 검증 도구 신설: -progressSelfTest (DEBUG) — 인메모리 컨테이너에 12시나리오·33검증(보고 케이스, 주간/월간/요일 스케줄/마감/atMost/특정 기간/잔존 deadline/무기록 + 목표 합산 회귀) 구성해 손계산 기대값과 대조, ALL PASS. 설정·날짜 고정이라 언제 실행해도 동일 판정
- 화면 확인: 목표 탭 '꾸준한 달리기'(주 2시간) 하루 233%→100%, 주간 67%·월간 30% 유지, 매일 다짐 117%/133%(자기 주기)·atMost·마감 다짐 표기 불변
- 도움말 '진행률 읽는 법'에 페이스 기준 설명 추가(en/ja 완비), CLAUDE.md §4.2 규칙·§14 인자 기재
- §2.3 l10n 루틴 강화: stringsdata를 Objects-normal/arm64로 한정 — x86_64·Release 등 낡은 구성 파일이 수정 전 문구를 계속 추출시키는 오염 발견·수정

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-16 22:35:23 +09:00
parent bdc771455b
commit 3ae49faad7
6 changed files with 7933 additions and 7732 deletions

View File

@ -67,9 +67,12 @@ xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim-Store \
# 1) Debug 빌드 → 2) stringsdata 동기화 — ⚠️ 카탈로그마다 "자기 타깃"의 stringsdata만 넣을 것
# (경로의 <타깃명>.build로 구분. 전체를 한꺼번에 넣으면 위젯 전용 문자열이
# IOS 카탈로그에 미번역으로 추가됐다가 Xcode 재저장 때 stale로 남는 오염이 생긴다)
app_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_Danim.build/*')"})
# ⚠️ 반드시 Objects-normal/arm64로 한정 — DerivedData에는 x86_64·Release·iphoneos 등
# 다른 구성의 낡은 stringsdata가 남아 있어, 넓은 패턴으로 잡으면 방금 수정한 문구의
# 옛 버전이 계속 "추출 중"으로 부활한다(진행률 도움말 수정 때 실제 발생)
app_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_Danim.build/Objects-normal/arm64/*')"})
xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]}"
widget_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_DanimWidgets.build/*')"})
widget_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_DanimWidgets.build/Objects-normal/arm64/*')"})
xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_files[@]}"
# 3) python으로 en/ja 번역 채움 → 4) sync 재실행으로 포맷 정규화 → 5) missing 확인
```
@ -112,7 +115,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 4.2 다짐 진행률 (QuestProgress)
- `ratio`(게이지용, 0...1): atLeast = min(value/target, 1) / **atMost = 한도 안이면 1, 넘으면 0**
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능 — 단 **주기와 다른 span의 환산 목표(페이스 가이드)에서는 값을 목표에서 캡해 초과 표기 없음**(주 2회 다짐을 오늘 1회 한 것이 "하루 350%"로 표기되던 왜곡 수정 — `spanProgress``usesScaledTarget` 분기). 자기 주기 span(하루 다짐의 '하루' 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, atMost는 값 무캡(하루 게이지의 환산 한도 초과 0%는 페이스 경고로 유효). 수치 검증은 `-progressSelfTest`(§14)
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
@ -320,7 +323,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|---|---|
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 33케이스 자가 검증 → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |

View File

@ -49,6 +49,7 @@ struct ContentView: View {
DebugSeed.pinGoalsIfRequested(context: context)
DebugSeed.endGoalYesterdayIfRequested(context: context)
DebugSeed.dumpStreaksIfRequested(context: context)
DebugSeed.runProgressSelfTestIfRequested()
await DebugSeed.runIntentSmokeTestIfRequested()
// : -themeAutoToggle YES 3 (App Group defaults)
// ' '

View File

@ -155,6 +155,183 @@ enum DebugSeed {
log("완료", "스모크 테스트 끝")
}
/// `-progressSelfTest YES` : (spanProgress) .
/// // (displayRatio)·
/// (ratio)· Documents/progress-self-test.txt .
/// · 00:00 12:00
/// · ( ).
/// : span ' ' 100% ,
/// span· , ' ' (§4.2).
@MainActor
static func runProgressSelfTestIfRequested() {
guard UserDefaults.standard.bool(forKey: "progressSelfTest") else { return }
var lines: [String] = []
var failures = 0
func expect(_ label: String, _ actual: Double, _ expected: Double) {
let pass = abs(actual - expected) < 0.0005
if !pass { failures += 1 }
lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(String(format: "%.4f", actual)) / expected \(String(format: "%.4f", expected))")
}
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: DataStore.schema, configurations: [config])
let context = container.mainContext
let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0))
let cal = math.calendar
let weekStart = math.weekRange(containing: .now).lowerBound
/// () + offset hour:minute
func at(_ dayOffset: Int, _ hour: Int, _ minute: Int = 0) -> Date {
cal.date(byAdding: .minute, value: hour * 60 + minute,
to: cal.date(byAdding: .day, value: dayOffset, to: weekStart)!)!
}
let now = at(3, 12) // 12:00
let monthDays = Double(math.dayKeys(in: math.monthRange(containing: now)).count)
lines.append("기준: 목요일 12:00 (\(now)), 이번 달 일수 \(Int(monthDays))")
/// = + + ( )
func makeQuest(_ name: String, type: TrackingType, period: QuestPeriod,
direction: QuestDirection = .atLeast,
targetSeconds: Double = 3600, targetCount: Int = 1,
configure: (Quest) -> Void = { _ in }) -> (Action, Quest) {
let goal = Goal(title: name, symbolName: "flag.fill", colorHex: "#2F6B4F",
startDate: cal.date(byAdding: .day, value: -30, to: now)!, endDate: nil)
context.insert(goal)
let action = Action(name: name, symbolName: "star.fill", trackingType: type, sortOrder: 0)
context.insert(action)
let quest = Quest(goal: goal)
quest.targetAction = action
quest.measure = type
quest.period = period
quest.direction = direction
quest.targetSeconds = targetSeconds
quest.targetCount = targetCount
configure(quest)
context.insert(quest)
return (action, quest)
}
func add(_ action: Action, _ when: Date, amount: Int = 1) {
context.insert(CountEntry(action: action, timestamp: when, amount: amount))
}
func addSession(_ action: Action, _ start: Date, _ end: Date) {
context.insert(TimeSession(action: action, startAt: start, endAt: end))
}
func progress(_ quest: Quest, _ span: StatSpan) -> QuestProgressResult {
QuestProgress(quest: quest, math: math).spanProgress(span, now: now)
}
// A. 2 , 1 ( : 350% 100%)
let (aAct, aQuest) = makeQuest("A 주2회", type: .count, period: .weekly, targetCount: 2)
add(aAct, at(3, 10))
expect("A 하루 표기(버그 수정)", progress(aQuest, .day).displayRatio, 1.0)
expect("A 하루 게이지", progress(aQuest, .day).ratio, 1.0)
expect("A 주간 표기", progress(aQuest, .week).displayRatio, 0.5)
expect("A 월간 표기(환산 캡 이하)", progress(aQuest, .month).displayRatio, 7.0 / (2.0 * monthDays))
if let goal = aQuest.goal {
expect("A 목표 하루 진행률(회귀 무영향)", goal.combinedSpanRatio(.day, math: math, now: now), 1.0)
expect("A 목표 주간 진행률", goal.combinedSpanRatio(.week, math: math, now: now), 0.5)
}
// B. 2 , ·· 3 ( )
let (bAct, bQuest) = makeQuest("B 주2회 3번", type: .count, period: .weekly, targetCount: 2)
add(bAct, at(0, 10)); add(bAct, at(1, 10)); add(bAct, at(3, 10))
expect("B 주간 표기(자기 주기 150% 유지)", progress(bQuest, .week).displayRatio, 1.5)
expect("B 주간 게이지", progress(bQuest, .week).ratio, 1.0)
expect("B 하루 표기(캡)", progress(bQuest, .day).displayRatio, 1.0)
// C. 7 , 2 ( : 200% 100%)
let (cAct, cQuest) = makeQuest("C 주7시간", type: .time, period: .weekly, targetSeconds: 7 * 3600)
addSession(cAct, at(3, 9), at(3, 11))
expect("C 하루 표기(캡)", progress(cQuest, .day).displayRatio, 1.0)
expect("C 주간 표기", progress(cQuest, .week).displayRatio, 2.0 / 7.0)
// D. 5 , 10 ( 200% + )
let (dAct, dQuest) = makeQuest("D 하루5회", type: .count, period: .daily, targetCount: 5)
add(dAct, at(3, 9), amount: 10)
expect("D 하루 표기(자기 주기 200% 유지)", progress(dQuest, .day).displayRatio, 2.0)
expect("D 하루 게이지", progress(dQuest, .day).ratio, 1.0)
expect("D 주간 표기(하루 기여 캡)", progress(dQuest, .week).displayRatio, 5.0 / 35.0)
// E. 5 : 5, 10 ( 5 , = 5× 3)
let (eAct, eQuest) = makeQuest("E 월수금5회", type: .count, period: .daily, targetCount: 5) {
$0.scheduleMode = .weekdays
$0.weekdays = [2, 4, 6]
}
add(eAct, at(0, 9), amount: 5); add(eAct, at(2, 9), amount: 10)
expect("E 주간 표기", progress(eQuest, .week).displayRatio, 10.0 / 15.0)
expect("E 하루 표기(목요일 0회)", progress(eQuest, .day).displayRatio, 0.0)
// F. 10 , 1 ( + )
let (fAct, fQuest) = makeQuest("F 월10회", type: .count, period: .monthly, targetCount: 10)
add(fAct, at(3, 10))
expect("F 하루 표기(캡)", progress(fQuest, .day).displayRatio, 1.0)
expect("F 주간 표기", progress(fQuest, .week).displayRatio, monthDays / 70.0)
expect("F 월간 표기", progress(fQuest, .month).displayRatio, 0.1)
// G. 10 , 12 ( 120% , · )
let (gAct, gQuest) = makeQuest("G 월10회 12번", type: .count, period: .monthly, targetCount: 10)
add(gAct, at(3, 10), amount: 12)
expect("G 월간 표기(자기 주기 120% 유지)", progress(gQuest, .month).displayRatio, 1.2)
expect("G 하루 표기(캡)", progress(gQuest, .day).displayRatio, 1.0)
expect("G 주간 표기(캡)", progress(gQuest, .week).displayRatio, 1.0)
// H. ' ' 7, 3 ( 0%, 100%)
let (hAct, hQuest) = makeQuest("H 이하7시간", type: .time, period: .weekly,
direction: .atMost, targetSeconds: 7 * 3600)
addSession(hAct, at(3, 9), at(3, 12))
expect("H 하루 게이지(환산 한도 초과 경고)", progress(hQuest, .day).ratio, 0.0)
expect("H 하루 값(무캡 — UI 초과 판정용)", progress(hQuest, .day).value, 3 * 3600)
expect("H 주간 게이지(한도 안)", progress(hQuest, .week).ratio, 1.0)
// I. (10) 10 , 12 ( span )
let (iAct, iQuest) = makeQuest("I 기간10회", type: .count, period: .custom, targetCount: 10) {
$0.customStart = at(0, 0)
$0.customEnd = at(9, 0)
}
add(iAct, at(3, 10), amount: 12)
expect("I 하루 표기(기간 다짐 무캡)", progress(iQuest, .day).displayRatio, 1.2)
expect("I 주간 표기(기간 다짐 무캡)", progress(iQuest, .week).displayRatio, 1.2)
// J. deadlineMinutes ( A )
let (jAct, jQuest) = makeQuest("J 주2회+잔존마감", type: .count, period: .weekly, targetCount: 2) {
$0.deadlineMinutes = 480
}
add(jAct, at(3, 10))
expect("J 하루 표기(마감 무시)", progress(jQuest, .day).displayRatio, 1.0)
expect("J 주간 표기(마감 무시)", progress(jQuest, .week).displayRatio, 0.5)
// K. 1 + 08:00 ( : 07:30 , 08:10 )
let (kAct, kQuest) = makeQuest("K 마감내", type: .count, period: .daily, targetCount: 1) {
$0.deadlineMinutes = 480
}
add(kAct, at(3, 7, 30))
expect("K 하루 표기(마감 안 1회)", progress(kQuest, .day).displayRatio, 1.0)
let (k2Act, k2Quest) = makeQuest("K2 마감밖", type: .count, period: .daily, targetCount: 1) {
$0.deadlineMinutes = 480
}
add(k2Act, at(3, 8, 10))
expect("K2 하루 표기(마감 뒤 제외)", progress(k2Quest, .day).displayRatio, 0.0)
// L. (0% )
let (_, lQuest) = makeQuest("L 무기록", type: .count, period: .weekly, targetCount: 2)
expect("L 하루 표기", progress(lQuest, .day).displayRatio, 0.0)
expect("L 주간 표기", progress(lQuest, .week).displayRatio, 0.0)
} catch {
failures += 1
lines.append("FAIL 컨테이너 생성: \(error)")
}
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
for line in lines { print("[ProgressTest] \(line)") }
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
try? lines.joined(separator: "\n")
.write(to: docs.appendingPathComponent("progress-self-test.txt"),
atomically: true, encoding: .utf8)
}
}
/// `-seedBulk YES` :
/// 500 2( 2) + 2( 8),
/// 1 . · .

File diff suppressed because it is too large Load Diff

View File

@ -135,7 +135,7 @@ struct HelpView: View {
HelpTopic(
symbol: "chart.bar.fill",
title: "진행률 읽는 법",
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산해요. '이하 유지' 다짐은 한도를 넘지 않는 동안 100%로 표시돼요."
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산해요. 다짐 자신의 주기(하루 다짐의 '하루' 등)에서는 100%를 넘는 초과 달성이 그대로 표시되지만, 주간 다짐의 '하루'처럼 주기와 다른 기간은 목표를 그 기간 몫으로 나눈 페이스 기준이라 최대 100%까지만 표시돼요. '이하 유지' 다짐은 한도를 넘지 않는 동안 100%로 표시돼요."
),
HelpTopic(
symbol: "clock.badge.checkmark",

View File

@ -202,14 +202,34 @@ struct QuestProgress {
}
}
/// span , span
/// span , span .
/// span ** ** ,
/// ' ' 2
/// 1 " 350%"(1 ÷ 2/7) . span
/// ( '', '' ) 100% .
/// ' ' (0%)
/// , UI
/// (value > target) .
func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult {
let range = spanRange(span, now: now)
return QuestProgressResult(
value: spanValue(for: span, range: range, now: now),
target: scaledTarget(for: span, range: range, now: now),
direction: quest.direction
)
let target = scaledTarget(for: span, range: range, now: now)
var value = spanValue(for: span, range: range, now: now)
if quest.direction == .atLeast, target > 0, usesScaledTarget(for: span) {
value = min(value, target)
}
return QuestProgressResult(value: value, target: target, direction: quest.direction)
}
/// span .
/// span , span
/// ( ) false .
private func usesScaledTarget(for span: StatSpan) -> Bool {
switch quest.period {
case .daily: return span != .day
case .weekly: return span != .week
case .monthly: return span != .month
case .custom: return false
}
}
/// span .