fix(goals): cap daily progress contributions to 100% to prevent weekly and monthly progress distortion

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-12 22:38:35 +09:00
parent c7024ea612
commit 4a03868163

View File

@ -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<Date>, 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<Date>, now: Date) -> Double {
let target = quest.targetValue