From 4a0386816311f37130412a940ed120b06cef1e63 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sun, 12 Jul 2026 22:38:35 +0900 Subject: [PATCH] fix(goals): cap daily progress contributions to 100% to prevent weekly and monthly progress distortion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly/monthly progress for a daily quest summed each day's raw value into the numerator, so one over-achieving day leaked its overage into the span total. Example: two daily quests with a 5-minute target; on one day quest A did 5 min (100%) and quest B did 10 min (200%). Both are "done" for that day, but the weekly ratio counted quest B as 600s vs quest A's 300s — B carried double the weight, and its over-achievement masked other days' shortfalls and could push per-quest weekly display past 100%. spanProgress now aggregates a daily 'atLeast' quest's week/month value as the sum of per-day values each capped at that day's target (min(dayValue, target)), via a new private spanValue(for:range:now:). This equals averaging per-day capped ratios, so a single 200% day contributes exactly one day's worth — no day can cover for another, and per-quest weekly/monthly display never exceeds 100%. Scope is deliberately narrow: - day span is untouched — a single day still shows 200% over-achievement. - 'atMost' (stay-under) quests keep their existing per-day 100%/0% logic; capping the value would make them always pass, so they use the raw value unchanged — the two rules coexist without contradiction. - weekly/monthly/custom quests accumulate freely within their period, so no per-day cap applies to them. Only the progress calculation changed; raw records are never altered, so the records/stats screens still show the true 10-minute total. Verified in the simulator with a temporary isolated in-memory self-test (removed before commit): quest A (100%) and quest B (200%) produce identical weekly ratios (0.1429 each), quest B's weekly display stays <= 100%, and its day-span display remains 200%. Build succeeds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/Shared/QuestProgress.swift | 33 +++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 9e691e6..20458fd 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -155,12 +155,43 @@ struct QuestProgress { case .month: range = math.monthRange(containing: now) } return QuestProgressResult( - value: value(in: range, now: now), + value: spanValue(for: span, range: range, now: now), target: scaledTarget(for: span, range: range, now: now), direction: quest.direction ) } + /// span 구간의 집계 값. + /// 주간·월간에서 '이상 달성' 하루 단위 다짐은 **하루별 기여를 그날의 목표치로 제한(캡)** 한 뒤 합산한다. + /// 어떤 하루의 과도한 초과 달성이 다른 날의 미달을 덮어써 전체 진행률을 왜곡하는 것을 막기 위함이다. + /// (예: 하루 목표 5분인데 어느 날 10분을 해도 그날은 5분어치만 인정 → 다른 날 미달이 가려지지 않음) + /// 원본 기록은 그대로 두고 오직 이 계산에서만 상한을 적용하므로 기록·통계 화면의 총 누적값에는 영향이 없다. + /// - 하루 span: 그날의 실제 누적을 그대로 써서 200% 같은 초과 달성 표기를 유지한다. + /// - '이하 유지'(atMost): 값이 아니라 하루 초과 여부(100%/0%)로 판정되므로 캡 대상이 아니다(기존 로직 유지). + /// - 주/월/특정기간 단위 다짐: 기간 내 자유 누적이 정상 동작이라 캡 없이 전체 누적값을 쓴다. + private func spanValue(for span: StatSpan, range: Range, now: Date) -> Double { + guard span != .day, + quest.period == .daily, + quest.direction == .atLeast, + quest.targetValue > 0 + else { + return value(in: range, now: now) + } + let dailyTarget = quest.targetValue + let agg = Aggregator(math: math) + var sum = 0.0 + for key in math.dayKeys(in: range) where isActiveDay(key) { + let dayRange = math.dayRange(forKey: key) + let dayValue: Double + switch quest.measure { + case .time: dayValue = agg.seconds(for: quest.targetActions, in: dayRange, now: now) + case .count: dayValue = Double(agg.count(for: quest.targetActions, in: dayRange)) + } + sum += min(dayValue, dailyTarget) + } + return sum + } + /// 주기 목표량을 span 길이에 맞춰 환산 private func scaledTarget(for span: StatSpan, range: Range, now: Date) -> Double { let target = quest.targetValue