- 통계 탭: 주간/월간 세그먼트 아래 '지난 7일/30일 단위로 보기' 토글(기기 로컬 stats.rollingWindow) - StatSpan enum 불변(다짐 진행률 공유) — statsRange 헬퍼·rolling 플래그로 통계 표면만 처리 - 비교 카드 '이전 7일/30일과 비교', 화살표 7/30일 점프, 롤링 월간은 주차별 카드·주 평균 열 제외 - 위젯 ⑤: StatsChartSpan에 last7/last30 추가(소형은 last30→last7 강등), DayMath.rollingRange 공유 - 통계 내보내기: rolling 반영(제목·기간·합계 문구·last7/last30 슬러그) - -statRolling 검증 인자, 주간·월간 롤링 화면 시뮬 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
152 lines
5.8 KiB
Swift
152 lines
5.8 KiB
Swift
//
|
|
// DayMath.swift
|
|
// Haru_Danim
|
|
//
|
|
// "하루 시작 시간" 기준의 논리적 하루 계산과 세션 분할 (CLAUDE.md §4.3)
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 논리적 하루(하루 시작 시간 반영), 주/월 범위, 세션 분할 계산
|
|
struct DayMath {
|
|
let settings: TrackingSettings
|
|
|
|
init(settings: TrackingSettings = .current()) {
|
|
self.settings = settings
|
|
}
|
|
|
|
var calendar: 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))
|
|
}
|
|
}
|
|
}
|