// // StatsTabView.swift // Haru_Danim // // 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터 // - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교) // - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표 // - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표 // import SwiftUI import SwiftData import Charts struct StatsTabView: View { @Environment(AppRouter.self) private var router @Query private var sessions: [TimeSession] @Query private var entries: [CountEntry] @Query(sort: \Tag.sortOrder) private var tags: [Tag] @Query(sort: \Action.sortOrder) private var allActions: [Action] @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 excludedActionIDs: Set = [] private var math: DayMath { DayMath() } private var spanRange: Range { let anchor = math.dayRange(forKey: anchorDayKey).lowerBound switch span { case .day: return math.dayRange(containing: anchor) case .week: return math.weekRange(containing: anchor) case .month: return math.monthRange(containing: anchor) } } 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) if isFiltering { filterChip } ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 16) { switch span { case .day: dayCharts case .week: weekCharts case .month: monthCharts } } .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 } } } .background(AppTheme.background) .navigationTitle("통계") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showingFilter = true } label: { Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } } } .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 } #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()) } 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()) } 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 "오늘" case .week: return "이번 주" case .month: return "이번 달" } } 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: 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: anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)! } } // MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택) private var isFiltering: Bool { !excludedActionIDs.isEmpty } /// 행동이 통계에 포함되는지: 행동 단위로만 판정 (꼬리표 일부 행동만 선택해도 정상 반영) 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) } .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: 데이터 수집 (구간 겹침으로 하루 경계 분할 자동 반영) private func segments(in range: Range) -> [SessionSegmentItem] { let now = Date.now var items: [SessionSegmentItem] = [] for session in sessions { guard let action = session.action, passesFilter(action) else { continue } let end = session.endAt ?? now let lower = max(session.startAt, range.lowerBound) let upper = min(end, range.upperBound) guard lower < upper else { continue } items.append(SessionSegmentItem(session: session, action: action, range: lower..) -> [CountItem] { entries .filter { range.contains($0.timestamp) } .compactMap { entry in entry.action.map { CountItem(entry: entry, action: $0) } } .filter { passesFilter($0.action) } } /// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게) private var elapsedDayCount: Int { max( math.dayKeys(in: spanRange) .filter { math.dayRange(forKey: $0).lowerBound <= .now } .count, 1 ) } // MARK: 하루 — 꼬리표별 가로 막대 (비율 비교) @ViewBuilder private var dayCharts: some View { let stats = tagStats(in: spanRange) tagTimeBarCard(stats) tagCountBarCard(stats) } private func tagStats(in range: Range) -> [TagStat] { var seconds: [String: TimeInterval] = [:] var countsByTag: [String: Int] = [:] var colors: [String: Color] = [:] func tagNames(for action: Action) -> [(String, Color)] { if action.tags.isEmpty { return [("꼬리표 없음", Color.gray)] } return action.sortedTags.map { ($0.name, $0.color) } } for item in segments(in: range) { for (name, color) in tagNames(for: item.action) { seconds[name, default: 0] += item.duration colors[name] = color } } for item in counts(in: range) { for (name, color) in tagNames(for: item.action) { countsByTag[name, default: 0] += item.entry.amount colors[name] = color } } let names = Set(seconds.keys).union(countsByTag.keys) return names .map { name in TagStat( name: name, color: colors[name] ?? .gray, seconds: seconds[name] ?? 0, count: countsByTag[name] ?? 0 ) } .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: 주간 — 행동별 일별 꺾은선 + 주간 합계 막대 @ViewBuilder private var weekCharts: some View { dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true) totalsBarCard("주간 시간 합계", type: .time) totalsBarCard("주간 횟수 합계", type: .count) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time), countRows: actionSummaryRows(for: .count) ) summaryTableCard( "꼬리표별 합계·평균", timeRows: tagSummaryRows(for: .time), countRows: tagSummaryRows(for: .count) ) } // MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대 @ViewBuilder private var monthCharts: some View { weeklyLineCard("행동별 시간 (주차별)", type: .time) weeklyLineCard("행동별 횟수 (주차별)", type: .count) dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false) dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false) totalsBarCard("월간 시간 합계", type: .time) totalsBarCard("월간 횟수 합계", type: .count) summaryTableCard( "행동별 합계·평균", timeRows: actionSummaryRows(for: .time), countRows: actionSummaryRows(for: .count) ) summaryTableCard( "꼬리표별 합계·평균", timeRows: tagSummaryRows(for: .time), countRows: tagSummaryRows(for: .count) ) } // 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) -> [Action] { let agg = Aggregator(math: math) return filteredActions .filter { $0.trackingType == type } .filter { action in switch type { case .time: return agg.seconds(for: action, in: spanRange) > 0 case .count: return agg.count(for: action, in: spanRange) > 0 } } } private func dailyPoints(for actions: [Action], type: TrackingType) -> [ActionDayPoint] { let agg = Aggregator(math: math) var result: [ActionDayPoint] = [] for key in math.dayKeys(in: spanRange) { let range = math.dayRange(forKey: key) for action in actions { let value: Double = switch type { case .time: agg.seconds(for: action, in: range) / 3600 case .count: Double(agg.count(for: action, in: range)) } result.append(ActionDayPoint(dayKey: key, actionName: action.name, value: value)) } } 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] { let agg = Aggregator(math: math) var result: [ActionWeekPoint] = [] for (index, weekRange) in weekRangesInMonth.enumerated() { for action in actions { let value: Double = switch type { case .time: agg.seconds(for: action, in: weekRange) / 3600 case .count: Double(agg.count(for: action, in: weekRange)) } result.append(ActionWeekPoint(weekLabel: "\(index + 1)주차", actionName: action.name, value: value)) } } return result } private func totals(for type: TrackingType) -> [ActionTotal] { let agg = Aggregator(math: math) return activeActions(for: type) .map { action in let value: Double = switch type { case .time: agg.seconds(for: action, in: spanRange) case .count: Double(agg.count(for: action, in: spanRange)) } return ActionTotal(name: action.name, color: action.color, value: value) } .sorted { $0.value > $1.value } } private func valueLabel(_ value: Double, type: TrackingType) -> String { switch type { case .time: return Format.durationShort(value) case .count: return "\(Int(value.rounded()))회" } } // MARK: 꺾은선 카드 (선 색 = 행동/태그 색, 범례 표시) @ViewBuilder private func dailyLineCard(_ title: String, type: TrackingType, weekdayAxis: Bool) -> some View { let actions = activeActions(for: type) let names = actions.map(\.name) let colors = actions.map(\.color) chartCard(title) { if actions.isEmpty { emptyChartText } else { Chart(dailyPoints(for: actions, type: type)) { point in LineMark( x: .value("날짜", point.dayKey, unit: .day), y: .value(type == .time ? "시간" : "횟수", 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 ? "시간(h)" : "횟수") .frame(height: 200) } } } @ViewBuilder private func weeklyLineCard(_ title: String, type: TrackingType) -> some View { let actions = activeActions(for: type) let names = actions.map(\.name) let colors = actions.map(\.color) chartCard(title) { if actions.isEmpty { emptyChartText } else { Chart(weeklyPoints(for: actions, type: type)) { point in LineMark( x: .value("주차", point.weekLabel), y: .value(type == .time ? "시간" : "횟수", point.value) ) .foregroundStyle(by: .value("행동", point.actionName)) .symbol(by: .value("행동", point.actionName)) .interpolationMethod(.monotone) } .chartForegroundStyleScale(domain: names, range: colors) .chartYAxisLabel(type == .time ? "시간(h)" : "횟수") .frame(height: 200) } } } // MARK: 합계 막대 카드 (기간 누적 비율 비교) @ViewBuilder private func totalsBarCard(_ title: String, type: TrackingType) -> some View { let items = totals(for: type) chartCard(title) { if items.isEmpty { emptyChartText } else { Chart(items) { item in BarMark( x: .value(type == .time ? "시간" : "횟수", 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 ? "시간(h)" : "횟수") .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) -> [SummaryRowItem] { let agg = Aggregator(math: math) return activeActions(for: type) .map { action in let total: Double = switch type { case .time: agg.seconds(for: action, in: spanRange) case .count: Double(agg.count(for: action, in: spanRange)) } return SummaryRowItem(name: action.name, color: action.color, symbol: action.symbolName, total: total, type: type) } .sorted { $0.total > $1.total } } private func tagSummaryRows(for type: TrackingType) -> [SummaryRowItem] { let stats = tagStats(in: spanRange) 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 String(format: "%.1f회", value) } } @ViewBuilder private func summaryTableCard(_ title: String, 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: String, 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 { Text("주 평균") .gridColumnAlignment(.trailing) } } .font(.caption2) .foregroundStyle(.secondary) ForEach(rows) { row in GridRow { HStack(spacing: 8) { Image(systemName: 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 { Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type)) .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) } } } } } // MARK: 공통 private func chartCard(_ title: String, @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 } }