소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변): - HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거 - DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거) - 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명) - refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회) - QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존) - 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회) - 일기 건강 카드 칩 value() 이중 호출 제거 검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10 ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경. whats-new-1.5 3언어에 성능 개선 줄 추가. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
156 lines
6.2 KiB
Swift
156 lines
6.2 KiB
Swift
//
|
||
// DayMath.swift
|
||
// Haru_Danim
|
||
//
|
||
// "하루 시작 시간" 기준의 논리적 하루 계산과 세션 분할 (CLAUDE.md §4.3)
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 논리적 하루(하루 시작 시간 반영), 주/월 범위, 세션 분할 계산
|
||
struct DayMath {
|
||
let settings: TrackingSettings
|
||
/// 설정이 반영된 캘린더 — 저장 프로퍼티로 1회만 생성 (1.5(9) 최적화).
|
||
/// 예전에는 계산 프로퍼티라 접근할 때마다 Calendar.current 사본 생성+firstWeekday 변경이
|
||
/// 일어났는데, dayKey 한 번에 3회·dayKeys/splitByDay는 일수×수 회 접근이라 모든 집계에
|
||
/// 곱해지는 숨은 비용이었다. settings가 불변이므로 결과는 완전히 동일하다.
|
||
let calendar: Calendar
|
||
|
||
init(settings: TrackingSettings = .current()) {
|
||
self.settings = settings
|
||
self.calendar = settings.calendar
|
||
}
|
||
|
||
// MARK: 논리적 하루
|
||
|
||
/// `date`가 속하는 논리적 하루의 키(해당 달력일의 자정 Date).
|
||
/// 예: 하루 시작 06:00일 때 7/7 05:00 → 7/6이 키.
|
||
func dayKey(for date: Date) -> Date {
|
||
let startOfDay = calendar.startOfDay(for: date)
|
||
let boundary = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: startOfDay)!
|
||
if date < boundary {
|
||
return calendar.date(byAdding: .day, value: -1, to: startOfDay)!
|
||
}
|
||
return startOfDay
|
||
}
|
||
|
||
/// 키(자정 Date)가 가리키는 논리적 하루의 실제 시각 범위
|
||
func dayRange(forKey key: Date) -> Range<Date> {
|
||
let start = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: key)!
|
||
let nextKey = calendar.date(byAdding: .day, value: 1, to: key)!
|
||
let end = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: nextKey)!
|
||
return start..<end
|
||
}
|
||
|
||
func dayRange(containing date: Date) -> Range<Date> {
|
||
dayRange(forKey: dayKey(for: date))
|
||
}
|
||
|
||
// MARK: 주 / 월 범위
|
||
|
||
/// `date`가 속한 논리적 하루를 포함하는 주(주 시작 요일 설정 반영)의 범위
|
||
func weekRange(containing date: Date) -> Range<Date> {
|
||
var key = dayKey(for: date)
|
||
while calendar.component(.weekday, from: key) != calendar.firstWeekday {
|
||
key = calendar.date(byAdding: .day, value: -1, to: key)!
|
||
}
|
||
let lastKey = calendar.date(byAdding: .day, value: 6, to: key)!
|
||
return dayRange(forKey: key).lowerBound..<dayRange(forKey: lastKey).upperBound
|
||
}
|
||
|
||
/// '오늘 기준' 롤링 범위(1.4): 끝 하루 키를 포함해 뒤로 `days`일의 논리적 범위.
|
||
/// 통계 표면(탭·⑤ 위젯·통계 내보내기) 전용 — 다짐 진행률의 달력 주기와 무관하다
|
||
func rollingRange(endingAtKey key: Date, days: Int) -> Range<Date> {
|
||
let startKey = calendar.date(byAdding: .day, value: -(days - 1), to: key) ?? key
|
||
return dayRange(forKey: startKey).lowerBound..<dayRange(forKey: key).upperBound
|
||
}
|
||
|
||
/// `date`가 속한 논리적 하루를 포함하는 달의 범위
|
||
func monthRange(containing date: Date) -> Range<Date> {
|
||
let key = dayKey(for: date)
|
||
let comps = calendar.dateComponents([.year, .month], from: key)
|
||
let firstKey = calendar.date(from: comps)!
|
||
let dayCount = calendar.range(of: .day, in: .month, for: firstKey)!.count
|
||
let lastKey = calendar.date(byAdding: .day, value: dayCount - 1, to: firstKey)!
|
||
return dayRange(forKey: firstKey).lowerBound..<dayRange(forKey: lastKey).upperBound
|
||
}
|
||
|
||
/// 범위에 포함된 논리적 하루 키 목록
|
||
func dayKeys(in range: Range<Date>) -> [Date] {
|
||
var keys: [Date] = []
|
||
var key = dayKey(for: range.lowerBound)
|
||
while dayRange(forKey: key).lowerBound < range.upperBound {
|
||
keys.append(key)
|
||
key = calendar.date(byAdding: .day, value: 1, to: key)!
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// MARK: 세션 분할 (하루 경계 걸침 처리)
|
||
|
||
struct DaySegment {
|
||
let dayKey: Date
|
||
let range: Range<Date>
|
||
}
|
||
|
||
/// 시작~종료 구간을 논리적 하루 경계 기준으로 분할
|
||
func splitByDay(start: Date, end: Date) -> [DaySegment] {
|
||
guard start < end else { return [] }
|
||
var segments: [DaySegment] = []
|
||
var cursor = start
|
||
while cursor < end {
|
||
let key = dayKey(for: cursor)
|
||
let dayEnd = dayRange(forKey: key).upperBound
|
||
let segmentEnd = min(dayEnd, end)
|
||
segments.append(DaySegment(dayKey: key, range: cursor..<segmentEnd))
|
||
cursor = segmentEnd
|
||
}
|
||
return segments
|
||
}
|
||
}
|
||
|
||
// MARK: - 집계
|
||
|
||
/// 행동별 시간/횟수 집계 (분할 규칙은 구간 겹침으로 자연히 반영됨)
|
||
struct Aggregator {
|
||
let math: DayMath
|
||
|
||
init(math: DayMath = DayMath()) {
|
||
self.math = math
|
||
}
|
||
|
||
/// 지정 구간 내 행동의 총 시간(초). 진행 중 세션은 `now`까지 계산.
|
||
func seconds(for action: Action, in range: Range<Date>, now: Date = .now) -> TimeInterval {
|
||
action.sessions.reduce(0) { total, session in
|
||
let end = session.endAt ?? now
|
||
let overlapStart = max(session.startAt, range.lowerBound)
|
||
let overlapEnd = min(end, range.upperBound)
|
||
return total + max(0, overlapEnd.timeIntervalSince(overlapStart))
|
||
}
|
||
}
|
||
|
||
/// 지정 구간 내 행동의 총 횟수
|
||
func count(for action: Action, in range: Range<Date>) -> Int {
|
||
action.countEntries
|
||
.filter { range.contains($0.timestamp) }
|
||
.reduce(0) { $0 + $1.amount }
|
||
}
|
||
|
||
func seconds(for actions: [Action], in range: Range<Date>, now: Date = .now) -> TimeInterval {
|
||
actions.reduce(0) { $0 + seconds(for: $1, in: range, now: now) }
|
||
}
|
||
|
||
func count(for actions: [Action], in range: Range<Date>) -> Int {
|
||
actions.reduce(0) { $0 + count(for: $1, in: range) }
|
||
}
|
||
|
||
/// 오늘(논리적 하루)의 값 — 메인 버튼 표시용
|
||
func todayValue(for action: Action, now: Date = .now) -> Double {
|
||
let range = math.dayRange(containing: now)
|
||
switch action.trackingType {
|
||
case .time: return seconds(for: action, in: range, now: now)
|
||
case .count: return Double(count(for: action, in: range))
|
||
}
|
||
}
|
||
}
|