mycode/myApp/HaruDanim/Shared/DayMath.swift
songyc macbook e7868f453c feat(stats): '오늘 기준' 롤링 보기 — 주간→지난 7일·월간→지난 30일 (탭·⑤ 위젯·내보내기)
- 통계 탭: 주간/월간 세그먼트 아래 '지난 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
2026-08-11 16:44:51 +09:00

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))
}
}
}