// // StatsTabView.swift // Haru_Danim // // 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터 // - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교) // - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표 // - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표 // import SwiftUI import SwiftData import Charts /// 통계 탭 전용 기간 계산 (1.4 '오늘 기준' 롤링). /// ⚠️ StatSpan enum 자체는 다짐 진행률(위젯 ②③④·워치·시리)과 공유되므로 케이스를 /// 추가하지 말 것 — 롤링은 통계 표면에서만 이 함수·rolling 플래그로 처리한다. func statsRange(span: StatSpan, anchorDayKey: Date, rolling: Bool, math: DayMath) -> Range { let anchor = math.dayRange(forKey: anchorDayKey).lowerBound switch span { case .day: return math.dayRange(containing: anchor) case .week: return rolling ? math.rollingRange(endingAtKey: anchorDayKey, days: 7) : math.weekRange(containing: anchor) case .month: return rolling ? math.rollingRange(endingAtKey: anchorDayKey, days: 30) : math.monthRange(containing: anchor) } } struct StatsTabView: View { @Environment(AppRouter.self) private var router @Query(sort: \Tag.sortOrder) private var tags: [Tag] @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] // 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고) @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" private var allActions: [Action] { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } @State private var span: StatSpan = { #if DEBUG if let raw = UserDefaults.standard.string(forKey: "statSpan"), let span = StatSpan(rawValue: raw) { return span } #endif return .day }() @State private var anchorDayKey: Date = DayMath().dayKey(for: .now) @State private var showingFilter = false @State private var showingExport = false /// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일). 표시 설정이라 기기 로컬 @AppStorage(SettingsKeys.statsRollingWindow) private var rollingWindow = false /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동도 자동 포함됨. /// 꼬리표는 시트에서 소속 행동 일괄 토글용일 뿐, 판정은 행동 단위로만 한다. @State private var excludedActionIDs: Set = [] private var math: DayMath { DayMath() } /// 하루 span에는 롤링 개념이 없다 private var isRolling: Bool { rollingWindow && span != .day } private var spanRange: Range { statsRange(span: span, anchorDayKey: anchorDayKey, rolling: isRolling, math: math) } var body: some View { VStack(spacing: 0) { periodHeader Picker("기간", selection: $span) { ForEach(StatSpan.allCases) { span in Text(span.label).tag(span) } } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 8) // 주간/월간의 '오늘 기준' 롤링 토글 (1.4) — 월초·주초에 달력 주기가 거의 // 비어 보이는 허전함의 해소. 하루 세그먼트에서는 의미가 없어 숨긴다 if span != .day { Toggle(isOn: $rollingWindow.animation()) { Text(span == .week ? "지난 7일 단위로 보기" : "지난 30일 단위로 보기") .font(.caption.weight(.semibold)) } .toggleStyle(.switch) .controlSize(.mini) .tint(AppTheme.green) .padding(.horizontal) .padding(.bottom, 8) } if isFiltering { filterChip } // 통계 조회는 보고 있는 기간(하루/주/월) 범위로만 DB에서 가져온다 (전량 로드 방지). // 기간·날짜가 바뀌면 새 범위의 쿼리로 다시 만들어진다 (SwiftData 동적 쿼리 패턴). StatsChartsView( span: span, anchorDayKey: anchorDayKey, rolling: isRolling, excludedActionIDs: excludedActionIDs, orderedActions: allActions, showingExport: $showingExport ) } .background(AppTheme.background) .navigationTitle("통계") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showingExport = true } label: { Image(systemName: "square.and.arrow.up") } .accessibilityLabel(Text("내보내기")) } ToolbarItem(placement: .topBarTrailing) { Button { showingFilter = true } label: { Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } .accessibilityLabel(Text("통계 필터")) } } .sheet(isPresented: $showingFilter) { RecordFilterSheet( title: "통계 필터", tags: tags, actions: allActions, excludedActionIDs: $excludedActionIDs ) } .onAppear { consumePendingFilter() #if DEBUG // 검증용: -excludeActions "이름,이름"으로 필터 상태 주입, -statShowFilter YES로 시트 표시 if let raw = UserDefaults.standard.string(forKey: "excludeActions") { let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) excludedActionIDs = Set( allActions.filter { names.contains($0.name) }.map(\.persistentModelID) ) } if UserDefaults.standard.bool(forKey: "statShowFilter") { showingFilter = true } // 검증용: -statRolling YES → '오늘 기준' 롤링 토글 켠 상태로 시작 if UserDefaults.standard.bool(forKey: "statRolling") { rollingWindow = true } // 검증용: -showExport YES → 내보내기 시트 표시 if UserDefaults.standard.bool(forKey: "showExport") { showingExport = true } #endif } .onChange(of: router.pendingStatsActionID) { consumePendingFilter() } } /// 모음 탭 '통계 보기'에서 예약한 행동 필터를 적용 (오늘 기준 기간으로 이동) private func consumePendingFilter() { guard let id = router.pendingStatsActionID else { return } // 해당 행동만 남기고 모두 해제 excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id]) anchorDayKey = math.dayKey(for: .now) router.pendingStatsActionID = nil } // MARK: 기간 이동 헤더 private var periodHeader: some View { HStack { Button { movePeriod(-1) } label: { Image(systemName: "chevron.left") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("이전 기간")) Spacer() VStack(spacing: 1) { Text(periodLabel) .font(.headline) if spanRange.contains(.now) { Text(currentPeriodLabel) .font(.caption2) .foregroundStyle(AppTheme.green) } } Spacer() Button { movePeriod(1) } label: { Image(systemName: "chevron.right") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("다음 기간")) Button(currentPeriodLabel) { anchorDayKey = math.dayKey(for: .now) } .font(.caption) .buttonStyle(.bordered) .buttonBorderShape(.capsule) } .padding(.horizontal) .padding(.vertical, 10) .tint(AppTheme.green) } private var currentPeriodLabel: String { switch span { case .day: return String(localized: "오늘") case .week: return isRolling ? String(localized: "최근 7일") : String(localized: "이번 주") case .month: return isRolling ? String(localized: "최근 30일") : String(localized: "이번 달") } } private var periodLabel: String { let keys = math.dayKeys(in: spanRange) switch span { case .day: return Format.fullDate(anchorDayKey) case .week: guard let first = keys.first, let last = keys.last else { return "" } return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))" case .month: if isRolling { guard let first = keys.first, let last = keys.last else { return "" } return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))" } return anchorDayKey.formatted(.dateTime.year().month()) } } private func movePeriod(_ delta: Int) { let cal = math.calendar switch span { case .day: anchorDayKey = cal.date(byAdding: .day, value: delta, to: anchorDayKey)! case .week: anchorDayKey = cal.date(byAdding: .day, value: 7 * delta, to: anchorDayKey)! case .month: // 롤링(지난 30일)은 30일씩, 달력 월은 한 달씩 점프 if isRolling { anchorDayKey = cal.date(byAdding: .day, value: 30 * delta, to: anchorDayKey)! } else { anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)! } } } // MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택) private var isFiltering: Bool { // 삭제된 행동의 잔존 제외 ID는 무시 — 실재 행동이 하나라도 제외됐을 때만 필터 활성 // (안 그러면 제외했던 행동을 삭제한 뒤에도 칩·내보내기 필터 문구가 허위로 남는다) allActions.contains { !passesFilter($0) } } /// 행동이 통계에 포함되는지: 행동 단위로만 판정 (꼬리표 일부 행동만 선택해도 정상 반영) private func passesFilter(_ action: Action) -> Bool { !excludedActionIDs.contains(action.persistentModelID) } private var filteredActions: [Action] { allActions.filter(passesFilter) } private var filterChip: some View { HStack(spacing: 6) { Image(systemName: "line.3.horizontal.decrease") .font(.caption2.weight(.semibold)) Text("행동 \(filteredActions.count)/\(allActions.count)개 표시 중") .font(.caption.weight(.semibold)) .lineLimit(1) Button { withAnimation { excludedActionIDs = [] } } label: { Image(systemName: "xmark.circle.fill") .font(.caption) .foregroundStyle(.secondary) .padding(4) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(Text("필터 해제")) } .foregroundStyle(AppTheme.green) .padding(.leading, 10) .padding(.trailing, 2) .padding(.vertical, 4) .background(AppTheme.green.opacity(0.12), in: Capsule()) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) .padding(.bottom, 6) } } // MARK: - 통계 차트 (보고 있는 기간 범위로만 DB 조회 + 1회 버킷 집계) /// 통계 차트 본체. 두 가지 성능 원칙: /// 1) **범위 조회**: 보고 있는 기간(하루/주/월)에 겹치는 기록만 @Query로 가져온다 — /// 하루 경계를 걸친 세션·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" /// 겹침 조건으로 포함. 전량 로드하던 예전 구조는 기록이 수만 건이면 탭 진입이 느려졌다. /// 2) **1회 버킷 집계**: 카드(꺾은선·막대·표)마다 Aggregator로 기록을 다시 훑지 않고, /// 조회된 기록을 한 번만 지나가며 행동별·하루별 버킷을 만들어 모든 카드가 공유한다. /// 하루 분할은 DayMath.splitByDay — 기존 Aggregator와 같은 규칙이라 수치가 동일하다. private struct StatsChartsView: View { let span: StatSpan let anchorDayKey: Date /// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일 — 하루 span에서는 항상 false) let rolling: Bool let excludedActionIDs: Set let orderedActions: [Action] @Binding var showingExport: Bool @Query private var sessions: [TimeSession] @Query private var entries: [CountEntry] // 1.5(10): 건강 카드 — 표시 여부·지표는 모음 탭 타일 설정을 그대로 상속 (별도 설정 없음). // '건강 데이터 표시'(설정 → 건강 데이터)가 마스터 스위치라 끄면 타일과 함께 사라진다 @AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true @AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var healthMetricsRaw = "" /// 추이 카드에서 마지막으로 고른 지표 (기기 로컬 표시 설정 — statsRollingWindow와 같은 계층) @AppStorage(SettingsKeys.statsHealthMetric) private var statsHealthMetricRaw = "" private let math = DayMath() init(span: StatSpan, anchorDayKey: Date, rolling: Bool, excludedActionIDs: Set, orderedActions: [Action], showingExport: Binding) { self.span = span self.anchorDayKey = anchorDayKey self.rolling = rolling self.excludedActionIDs = excludedActionIDs self.orderedActions = orderedActions self._showingExport = showingExport let math = DayMath() let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math) let lower = range.lowerBound let upper = range.upperBound let farFuture = Date.distantFuture _sessions = Query(filter: #Predicate { $0.startAt < upper && ($0.endAt ?? farFuture) > lower }) _entries = Query(filter: #Predicate { $0.timestamp >= lower && $0.timestamp < upper }) } private var spanRange: Range { statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math) } // MARK: 이전 기간 비교 (화면 전용 — 내보내기 리포트에는 넣지 않는다, §6.6) /// 현재 기간 바로 앞의 같은 단위 기간 (롤링은 바로 앞 7일/30일 창) private var previousRange: Range { if rolling { let days = span == .week ? 7 : 30 let prevEndKey = math.calendar.date(byAdding: .day, value: -days, to: anchorDayKey) ?? anchorDayKey return math.rollingRange(endingAtKey: prevEndKey, days: days) } let justBefore = spanRange.lowerBound.addingTimeInterval(-1) switch span { case .day: return math.dayRange(containing: justBefore) case .week: return math.weekRange(containing: justBefore) case .month: return math.monthRange(containing: justBefore) } } /// 필터 통과 행동들의 기간 합계. 이전 기간 기록은 화면 @Query 범위 밖이라 /// 관계 기반 Aggregator로 직접 집계한다 (행동 상세의 누적 표시와 같은 경로 — 규칙 동일) private func periodTotals(in range: Range) -> (seconds: TimeInterval, count: Int) { let agg = Aggregator(math: math) var seconds: TimeInterval = 0 var count = 0 for action in filteredActions { switch action.trackingType { case .time: seconds += agg.seconds(for: action, in: range) case .count: count += agg.count(for: action, in: range) } } return (seconds, count) } private var previousPeriodTitle: LocalizedStringKey { if rolling { return span == .week ? "이전 7일과 비교" : "이전 30일과 비교" } switch span { case .day: return "어제와 비교" case .week: return "지난주와 비교" case .month: return "지난달과 비교" } } /// 총 시간·횟수를 바로 앞 기간과 비교하는 요약 카드 — 두 기간 모두 0인 유형은 줄을 생략하고, /// 둘 다 아무것도 없으면 카드 자체를 그리지 않는다 @ViewBuilder private var comparisonCard: some View { let current = periodTotals(in: spanRange) let previous = periodTotals(in: previousRange) let showTime = current.seconds > 0 || previous.seconds > 0 let showCount = current.count > 0 || previous.count > 0 if showTime || showCount { VStack(alignment: .leading, spacing: 10) { Text(previousPeriodTitle) .font(.subheadline.weight(.semibold)) if showTime { comparisonRow( symbol: "timer", title: String(localized: "시간"), currentLabel: Format.durationShort(current.seconds), previousLabel: Format.durationShort(previous.seconds), current: current.seconds, previous: previous.seconds ) } if showCount { comparisonRow( symbol: "number", title: String(localized: "횟수"), currentLabel: String(localized: "\(current.count)회"), previousLabel: String(localized: "\(previous.count)회"), current: Double(current.count), previous: Double(previous.count) ) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } private func comparisonRow(symbol: String, title: String, currentLabel: String, previousLabel: String, current: Double, previous: Double) -> some View { HStack(spacing: 8) { Image(systemName: symbol) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(AppTheme.green) .frame(width: 18) Text(title) .font(.caption) .foregroundStyle(.secondary) Spacer() VStack(alignment: .trailing, spacing: 1) { HStack(spacing: 6) { Text(currentLabel) .font(.footnote.weight(.semibold).monospacedDigit()) deltaBadge(current: current, previous: previous) } Text("이전 \(previousLabel)") .font(.caption2) .foregroundStyle(.tertiary) } } } /// 증감 배지 — 이전 기록이 없으면 퍼센트 대신 상태 문구 (0으로 나누는 왜곡 방지) @ViewBuilder private func deltaBadge(current: Double, previous: Double) -> some View { if previous <= 0 { Text("이전 기록 없음") .font(.caption2) .foregroundStyle(.tertiary) } else { let percent = Int(((current - previous) / previous * 100).rounded()) Group { if percent > 0 { Text(verbatim: "▲") + Text("\(percent)%") } else if percent < 0 { Text(verbatim: "▼") + Text("\(abs(percent))%") } else { Text("변화 없음") } } .font(.caption2.weight(.bold).monospacedDigit()) .foregroundStyle(percent > 0 ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary)) } } var body: some View { let data = makeStatsData() ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 16) { comparisonCard switch span { case .day: dayCharts(data) case .week: weekCharts(data) case .month: monthCharts(data) } // 건강 카드는 행동 통계 아래에 덧붙인다 (1.5(10) — 기존 화면 배치 불변) healthSection } .padding() Color.clear .frame(height: 1) .id("statsBottom") } .onAppear { #if DEBUG // 검증용: -statScrollBottom YES면 하단 요약 카드까지 자동 스크롤 if UserDefaults.standard.bool(forKey: "statScrollBottom") { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { withAnimation { proxy.scrollTo("statsBottom", anchor: .bottom) } } } #endif } } .sheet(isPresented: $showingExport) { // 지금 보고 있는 기간/필터 그대로 스냅숏을 만들어 이미지로 내보낸다 ExportImageSheet(snapshot: ExportBuilder.stats( span: span, anchorDayKey: anchorDayKey, rolling: rolling, sessions: sessions, entries: entries, orderedActions: orderedActions, excludedActionIDs: excludedActionIDs, math: math )) } } // MARK: 필터 private func passesFilter(_ action: Action) -> Bool { !excludedActionIDs.contains(action.persistentModelID) } private var filteredActions: [Action] { orderedActions.filter(passesFilter) } // MARK: 1회 버킷 집계 /// 행동별·하루별 집계 버킷 (조회된 기록을 한 번만 순회해 생성) private struct StatsData { var seconds: [PersistentIdentifier: [Date: TimeInterval]] = [:] var counts: [PersistentIdentifier: [Date: Int]] = [:] /// 기간의 논리적 하루 키 목록 var dayKeys: [Date] = [] func seconds(for id: PersistentIdentifier, days: [Date]) -> TimeInterval { guard let byDay = seconds[id] else { return 0 } return days.reduce(0) { $0 + (byDay[$1] ?? 0) } } func count(for id: PersistentIdentifier, days: [Date]) -> Int { guard let byDay = counts[id] else { return 0 } return days.reduce(0) { $0 + (byDay[$1] ?? 0) } } /// 행동의 추적 방식에 맞는 값 (시간=초, 횟수=회) func value(for action: Action, type: TrackingType, days: [Date]) -> Double { switch type { case .time: return seconds(for: action.persistentModelID, days: days) case .count: return Double(count(for: action.persistentModelID, days: days)) } } } private func makeStatsData() -> StatsData { var data = StatsData() let range = spanRange data.dayKeys = math.dayKeys(in: range) let now = Date.now for session in sessions { guard let action = session.action, passesFilter(action) else { continue } let start = max(session.startAt, range.lowerBound) let end = min(session.endAt ?? now, range.upperBound) guard start < end else { continue } for segment in math.splitByDay(start: start, end: end) { data.seconds[action.persistentModelID, default: [:]][segment.dayKey, default: 0] += segment.range.upperBound.timeIntervalSince(segment.range.lowerBound) } } for entry in entries { guard range.contains(entry.timestamp), let action = entry.action, passesFilter(action) else { continue } data.counts[action.persistentModelID, default: [:]][math.dayKey(for: entry.timestamp), default: 0] += entry.amount } return data } /// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게) private var elapsedDayCount: Int { max( math.dayKeys(in: spanRange) .filter { math.dayRange(forKey: $0).lowerBound <= .now } .count, 1 ) } // MARK: 하루 — 꼬리표별 가로 막대 (비율 비교) @ViewBuilder private func dayCharts(_ data: StatsData) -> some View { let stats = tagStats(data) tagTimeBarCard(stats) tagCountBarCard(stats) } /// 꼬리표별 합계 — 행동별 버킷 합계를 소속 꼬리표들에 배분 (기존 세그먼트 순회와 동일한 값). /// 키는 꼬리표 identity('꼬리표 없음' 자리는 nil) — 동명 꼬리표가 이름 키로 합산되던 것 방지, /// 표시 이름은 마지막에 Format.disambiguated로 구분한다 private func tagStats(_ data: StatsData) -> [TagStat] { struct Acc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 } var accs: [PersistentIdentifier?: Acc] = [:] var order: [PersistentIdentifier?] = [] // 첫 등장 순서 (구분 번호가 결정적이게) func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] { if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] } return action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) } } for action in filteredActions { let totalSeconds = data.seconds(for: action.persistentModelID, days: data.dayKeys) let totalCount = data.count(for: action.persistentModelID, days: data.dayKeys) guard totalSeconds > 0 || totalCount > 0 else { continue } for (key, name, color) in tagKeys(for: action) { if accs[key] == nil { accs[key] = Acc(name: name, color: color) order.append(key) } accs[key]?.seconds += totalSeconds accs[key]?.count += totalCount } } let ordered = order.compactMap { accs[$0] } let names = Format.disambiguated(ordered.map(\.name)) return zip(ordered, names) .map { acc, name in TagStat(name: name, color: acc.color, seconds: acc.seconds, count: acc.count) } .sorted { $0.seconds > $1.seconds } } @ViewBuilder private func tagTimeBarCard(_ stats: [TagStat]) -> some View { let timeStats = stats.filter { $0.seconds > 0 } chartCard("꼬리표별 시간 비교") { if timeStats.isEmpty { emptyChartText } else { Chart(timeStats) { stat in BarMark( x: .value("시간", stat.seconds / 3600), y: .value("꼬리표", stat.name) ) .foregroundStyle(stat.color) .cornerRadius(4) .annotation(position: .trailing) { Text(Format.durationShort(stat.seconds)) .font(.caption2) .foregroundStyle(.secondary) } } .chartXAxisLabel("시간(h)") .frame(height: CGFloat(timeStats.count) * 44 + 30) } } } @ViewBuilder private func tagCountBarCard(_ stats: [TagStat]) -> some View { let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count } chartCard("꼬리표별 횟수 비교") { if countStats.isEmpty { emptyChartText } else { Chart(countStats) { stat in BarMark( x: .value("횟수", stat.count), y: .value("꼬리표", stat.name) ) .foregroundStyle(stat.color) .cornerRadius(4) .annotation(position: .trailing) { Text("\(stat.count)회") .font(.caption2) .foregroundStyle(.secondary) } } .chartXAxisLabel("횟수") .frame(height: CGFloat(countStats.count) * 44 + 30) } } } // MARK: 주간 — 행동별 일별 꺾은선 + 주간 합계 막대 /// 합계 막대 제목 — 롤링이면 "지난 7일/30일", 달력 주기면 "주간/월간" private var totalsTimeTitle: LocalizedStringKey { if rolling { return span == .week ? "지난 7일 시간 합계" : "지난 30일 시간 합계" } return span == .week ? "주간 시간 합계" : "월간 시간 합계" } private var totalsCountTitle: LocalizedStringKey { if rolling { return span == .week ? "지난 7일 횟수 합계" : "지난 30일 횟수 합계" } return span == .week ? "주간 횟수 합계" : "월간 횟수 합계" } @ViewBuilder private func weekCharts(_ data: StatsData) -> some View { dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true, data: data) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true, data: data) totalsBarCard(totalsTimeTitle, type: .time, data: data) totalsBarCard(totalsCountTitle, type: .count, data: data) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time, data: data), countRows: actionSummaryRows(for: .count, data: data) ) summaryTableCard( "꼬리표별 합계·평균", timeRows: tagSummaryRows(for: .time, data: data), countRows: tagSummaryRows(for: .count, data: data) ) } // MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대 @ViewBuilder private func monthCharts(_ data: StatsData) -> some View { // 롤링(지난 30일)은 창이 달력 월과 어긋나 "N주차" 표기가 성립하지 않는다 — // 주차별 카드 없이 일별 선 + 합계 + 표 구성 (확장된 주간 보기와 동일한 문법) if !rolling { weeklyLineCard("행동별 시간 (주차별)", type: .time, data: data) weeklyLineCard("행동별 횟수 (주차별)", type: .count, data: data) } dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false, data: data) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false, data: data) totalsBarCard(totalsTimeTitle, type: .time, data: data) totalsBarCard(totalsCountTitle, type: .count, data: data) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time, data: data), countRows: actionSummaryRows(for: .count, data: data) ) summaryTableCard( "꼬리표별 합계·평균", timeRows: tagSummaryRows(for: .time, data: data), countRows: tagSummaryRows(for: .count, data: data) ) } // MARK: 행동별 시리즈 계산 private struct ActionDayPoint: Identifiable { let dayKey: Date let actionName: String let value: Double var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(actionName)" } } private struct ActionWeekPoint: Identifiable { let weekLabel: String let actionName: String let value: Double var id: String { "\(weekLabel)-\(actionName)" } } private struct ActionTotal: Identifiable { let name: String let color: Color let value: Double var id: String { name } } /// 기간 내 값이 있는(0이 아닌) 필터 통과 행동들 — 시리즈/범례 대상 private func activeActions(for type: TrackingType, data: StatsData) -> [Action] { filteredActions .filter { $0.trackingType == type } .filter { data.value(for: $0, type: type, days: data.dayKeys) > 0 } } private func dailyPoints(for actions: [Action], names: [String], type: TrackingType, data: StatsData) -> [ActionDayPoint] { var result: [ActionDayPoint] = [] let series = Array(zip(actions, names)) for key in data.dayKeys { for (action, name) in series { let raw = data.value(for: action, type: type, days: [key]) result.append(ActionDayPoint( dayKey: key, actionName: name, value: type == .time ? raw / 3600 : raw )) } } return result } /// 한 달을 주 단위(주 시작 요일 설정 기준, 월 경계에서 잘림)로 쪼갠 범위들 private var weekRangesInMonth: [Range] { let range = spanRange var result: [Range] = [] var cursor = range.lowerBound while cursor < range.upperBound { let week = math.weekRange(containing: cursor) result.append(max(week.lowerBound, range.lowerBound).. [ActionWeekPoint] { var result: [ActionWeekPoint] = [] let series = Array(zip(actions, names)) for (index, weekRange) in weekRangesInMonth.enumerated() { // 주 경계는 하루 단위로 정렬되므로 그 주에 속한 하루 버킷 합 = 기존 구간 집계와 동일 let weekDays = math.dayKeys(in: weekRange) for (action, name) in series { let raw = data.value(for: action, type: type, days: weekDays) // String(localized:)로 감싸야 en/ja에서도 지역화됨 (내보내기 ExportBuilder와 동일 키) result.append(ActionWeekPoint(weekLabel: String(localized: "\(index + 1)주차"), actionName: name, value: type == .time ? raw / 3600 : raw)) } } return result } private func totals(for type: TrackingType, data: StatsData) -> [ActionTotal] { let actions = activeActions(for: type, data: data) let names = Format.disambiguated(actions.map(\.name)) return zip(actions, names) .map { action, name in ActionTotal(name: name, color: action.color, value: data.value(for: action, type: type, days: data.dayKeys)) } .sorted { $0.value > $1.value } } private func valueLabel(_ value: Double, type: TrackingType) -> String { switch type { case .time: return Format.durationShort(value) case .count: return String(localized: "\(Int(value.rounded()))회") } } // MARK: 꺾은선 카드 (선 색 = 행동/태그 색, 범례 표시) @ViewBuilder private func dailyLineCard(_ title: LocalizedStringKey, type: TrackingType, weekdayAxis: Bool, data: StatsData) -> some View { let actions = activeActions(for: type, data: data) // 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙) let names = Format.disambiguated(actions.map(\.name)) let colors = actions.map(\.color) chartCard(title) { if actions.isEmpty { emptyChartText } else { Chart(dailyPoints(for: actions, names: names, type: type, data: data)) { point in LineMark( x: .value("날짜", point.dayKey, unit: .day), y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value) ) .foregroundStyle(by: .value("행동", point.actionName)) .symbol(by: .value("행동", point.actionName)) .interpolationMethod(.monotone) } .chartForegroundStyleScale(domain: names, range: colors) .chartXAxis { if weekdayAxis { AxisMarks(values: .stride(by: .day)) { value in AxisGridLine() AxisValueLabel { if let date = value.as(Date.self) { Text(Format.weekdayShort(math.calendar.component(.weekday, from: date))) } } } } else { AxisMarks { _ in AxisGridLine() AxisValueLabel(format: .dateTime.day()) } } } .chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수")) .frame(height: 200) } } } @ViewBuilder private func weeklyLineCard(_ title: LocalizedStringKey, type: TrackingType, data: StatsData) -> some View { let actions = activeActions(for: type, data: data) // 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙) let names = Format.disambiguated(actions.map(\.name)) let colors = actions.map(\.color) chartCard(title) { if actions.isEmpty { emptyChartText } else { Chart(weeklyPoints(for: actions, names: names, type: type, data: data)) { point in LineMark( x: .value("주차", point.weekLabel), y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value) ) .foregroundStyle(by: .value("행동", point.actionName)) .symbol(by: .value("행동", point.actionName)) .interpolationMethod(.monotone) } .chartForegroundStyleScale(domain: names, range: colors) .chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수")) .frame(height: 200) } } } // MARK: 합계 막대 카드 (기간 누적 비율 비교) @ViewBuilder private func totalsBarCard(_ title: LocalizedStringKey, type: TrackingType, data: StatsData) -> some View { let items = totals(for: type, data: data) chartCard(title) { if items.isEmpty { emptyChartText } else { Chart(items) { item in BarMark( x: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), type == .time ? item.value / 3600 : item.value), y: .value("행동", item.name) ) .foregroundStyle(item.color) .cornerRadius(4) .annotation(position: .trailing) { Text(valueLabel(item.value, type: type)) .font(.caption2) .foregroundStyle(.secondary) } } .chartXAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수")) .frame(height: CGFloat(items.count) * 40 + 30) } } } // MARK: 행동·꼬리표별 합계/평균 요약 표 (주간/월간, 필터 반영) private struct SummaryRowItem: Identifiable { let name: String let color: Color let symbol: String /// 시간형은 초, 횟수형은 횟수 let total: Double let type: TrackingType var id: String { "\(type == .time ? "time" : "count")-\(name)" } } /// 평균 계산용: 월 안에서 이미 시작된 주 수 (미래 주로 평균이 희석되지 않게) private var elapsedWeekCount: Int { max(weekRangesInMonth.filter { $0.lowerBound <= .now }.count, 1) } private func actionSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] { let actions = activeActions(for: type, data: data) let names = Format.disambiguated(actions.map(\.name)) return zip(actions, names) .map { action, name in SummaryRowItem(name: name, color: action.color, symbol: action.symbolName, total: data.value(for: action, type: type, days: data.dayKeys), type: type) } .sorted { $0.total > $1.total } } private func tagSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] { let stats = tagStats(data) let rows: [SummaryRowItem] = switch type { case .time: stats.filter { $0.seconds > 0 } .map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: $0.seconds, type: .time) } case .count: stats.filter { $0.count > 0 } .map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: Double($0.count), type: .count) } } return rows.sorted { $0.total > $1.total } } private func averageLabel(_ value: Double, type: TrackingType) -> String { switch type { case .time: return Format.durationShort(value) case .count: return Format.countAverage(value) } } @ViewBuilder private func summaryTableCard(_ title: LocalizedStringKey, timeRows: [SummaryRowItem], countRows: [SummaryRowItem]) -> some View { chartCard(title) { if timeRows.isEmpty && countRows.isEmpty { emptyChartText } else { VStack(alignment: .leading, spacing: 14) { if !timeRows.isEmpty { summaryGrid(sectionLabel: "시간", rows: timeRows) } if !timeRows.isEmpty && !countRows.isEmpty { Divider() } if !countRows.isEmpty { summaryGrid(sectionLabel: "횟수", rows: countRows) } } } } } private func summaryGrid(sectionLabel: LocalizedStringKey, rows: [SummaryRowItem]) -> some View { Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) { GridRow { Text(sectionLabel) .fontWeight(.semibold) Text("합계") .gridColumnAlignment(.trailing) Text("하루 평균") .gridColumnAlignment(.trailing) if span == .month && !rolling { Text("주 평균") .gridColumnAlignment(.trailing) } } .font(.caption2) .foregroundStyle(.secondary) ForEach(rows) { row in GridRow { HStack(spacing: 8) { Image(safeSymbol: row.symbol) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) .frame(width: 24, height: 24) .background(row.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) Text(row.name) .font(.footnote) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) Text(valueLabel(row.total, type: row.type)) .font(.footnote.weight(.semibold).monospacedDigit()) .foregroundStyle(AppTheme.green) Text(averageLabel(row.total / Double(elapsedDayCount), type: row.type)) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) if span == .month && !rolling { Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type)) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) } } } } } // MARK: 건강 데이터 (1.5(10) — HealthCache 인메모리 읽기만 하는 표시 전용 카드. // 통계 내보내기 리포트에는 넣지 않는다 — '이전 기간 비교 카드'와 같은 의도된 화면 전용) /// 표시 판정 — 건강 지원 기기 + 설정 '건강 데이터 표시' 켬 + 애플 건강 연결됨. /// 연결 전에는 조용히 숨긴다 (연결 유도는 모음 탭 CTA 담당 — 통계에서 권유하지 않음) private var showsHealth: Bool { HealthDataStore.isAvailable && healthTilesEnabled && HealthDataStore.shared.hasRequestedAuth } /// 표시 지표 = 모음 탭 타일 선택 상속 (일기 건강 카드와 같은 상속 문법) private var healthMetricsList: [HealthMetric] { HealthMetric.selectedList(raw: healthMetricsRaw) } /// 추이 카드의 현재 지표 — 저장값이 목록에 없으면(타일 선택 변경) 첫 지표로 폴백 private var chartHealthMetric: HealthMetric { if let saved = HealthMetric(rawValue: statsHealthMetricRaw), healthMetricsList.contains(saved) { return saved } return healthMetricsList.first ?? .steps } @ViewBuilder private var healthSection: some View { if showsHealth { if span == .day { // 하루: 그날 지표 값 칩 (일기 건강 카드와 같은 모습 — 값 없으면 대시) chartCard("건강 데이터") { HealthValueChipGrid(dayKey: anchorDayKey, metrics: healthMetricsList) } } else { let days = math.dayKeys(in: spanRange) let hasAny = days.contains { day in healthMetricsList.contains { HealthDisplayValues.value($0, dayKey: day) != nil } } if hasAny { healthTrendCard(days: days) healthSummaryCard(days: days) } else { // 캐시 보존(400일) 밖 과거 등 — 빈 카드 하나로 정리 chartCard("건강 데이터") { emptyHealthText } } } } } private struct HealthDayPoint: Identifiable { let dayKey: Date let value: Double var id: Date { dayKey } } /// 차트에 그릴 값 변환 — 시간류는 시간(h), 거리·물은 km·L, 나머지는 원 단위 private func healthChartValue(_ metric: HealthMetric, _ raw: Double) -> Double { if metric.isDuration { return raw / 3600 } switch metric { case .distance, .water: return raw / 1000 default: return raw } } /// 차트 Y축 단위 라벨 (kcal·km·L은 라틴 표기라 언어 무관) @ViewBuilder private func healthUnitLabel(_ metric: HealthMetric) -> some View { if metric.isDuration || metric == .standHours { Text("시간(h)") } else { switch metric { case .steps: Text("보") case .activeEnergy: Text(verbatim: "kcal") case .distance: Text(verbatim: "km") case .water: Text(verbatim: "L") default: Text(verbatim: "") } } } /// 지표별 일별 추이 카드 — 행동 꺾은선 카드와 같은 문법, 지표는 우상단 메뉴에서 선택 private func healthTrendCard(days: [Date]) -> some View { let metric = chartHealthMetric let points = days.map { day in HealthDayPoint( dayKey: day, value: healthChartValue(metric, HealthDisplayValues.value(metric, dayKey: day) ?? 0) ) } return VStack(alignment: .leading, spacing: 12) { HStack { Text("건강 지표 (일별)") .font(.subheadline.weight(.semibold)) Spacer() Menu { ForEach(healthMetricsList) { candidate in Button { statsHealthMetricRaw = candidate.rawValue } label: { if candidate == metric { Label(candidate.name, systemImage: "checkmark") } else { Text(candidate.name) } } } } label: { HStack(spacing: 4) { Image(safeSymbol: metric.symbolName) .font(.system(size: 11, weight: .semibold)) Text(metric.name) .font(.caption.weight(.semibold)) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 9, weight: .semibold)) } .foregroundStyle(.pink) .padding(.horizontal, 9) .padding(.vertical, 5) .background(Color.pink.opacity(0.12), in: Capsule()) } .accessibilityLabel(Text("추이를 볼 지표 선택")) } Chart(points) { point in LineMark( x: .value("날짜", point.dayKey, unit: .day), y: .value(metric.name, point.value) ) .foregroundStyle(.pink) .symbol(.circle) .interpolationMethod(.monotone) } .chartXAxis { if span == .week { AxisMarks(values: .stride(by: .day)) { value in AxisGridLine() AxisValueLabel { if let date = value.as(Date.self) { Text(Format.weekdayShort(math.calendar.component(.weekday, from: date))) } } } } else { AxisMarks { _ in AxisGridLine() AxisValueLabel(format: .dateTime.day()) } } } .chartYAxisLabel { healthUnitLabel(metric) } .frame(height: 200) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } /// 지표별 합계·하루 평균 표 — 행동 요약 표와 같은 문법 (값 없는 지표 행은 생략) private func healthSummaryCard(days: [Date]) -> some View { let rows: [(metric: HealthMetric, total: Double)] = healthMetricsList.compactMap { metric in let total = days.reduce(0.0) { $0 + (HealthDisplayValues.value(metric, dayKey: $1) ?? 0) } return total > 0 ? (metric, total) : nil } return chartCard("건강 지표 합계·평균") { if rows.isEmpty { emptyHealthText } else { Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) { GridRow { Text("지표") .fontWeight(.semibold) Text("합계") .gridColumnAlignment(.trailing) Text("하루 평균") .gridColumnAlignment(.trailing) if span == .month && !rolling { Text("주 평균") .gridColumnAlignment(.trailing) } } .font(.caption2) .foregroundStyle(.secondary) ForEach(rows, id: \.metric) { row in GridRow { HStack(spacing: 8) { Image(safeSymbol: row.metric.symbolName) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) .frame(width: 24, height: 24) .background(Color.pink, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) Text(row.metric.name) .font(.footnote) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) Text(row.metric.valueLabel(row.total)) .font(.footnote.weight(.semibold).monospacedDigit()) .foregroundStyle(.pink) Text(row.metric.valueLabel(row.total / Double(elapsedDayCount))) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) if span == .month && !rolling { Text(row.metric.valueLabel(row.total / Double(elapsedWeekCount))) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) } } } } } } } private var emptyHealthText: some View { Text("이 기간의 건강 데이터가 없어요") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 24) } // MARK: 공통 private func chartCard(_ title: LocalizedStringKey, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.subheadline.weight(.semibold)) content() } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } private var emptyChartText: some View { Text("표시할 기록이 없어요") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 24) } } // MARK: - 태그별 집계 항목 struct TagStat: Identifiable { let name: String let color: Color let seconds: TimeInterval let count: Int var id: String { name } }