// // StatsView.swift // Keging // // 통계 — 하루·주간·월간, 꺾은선 그래프. 주간·월간은 달력 기준 ↔ 오늘 기준 롤링 전환. // 첫 화면에서 아래로 스와이프해 열리고, 위로 스와이프하거나 X로 닫는다. // import SwiftUI import Charts enum StatScope: String, CaseIterable, Identifiable { case day, week, month var id: String { rawValue } var title: LocalizedStringKey { switch self { case .day: "하루" case .week: "주간" case .month: "월간" } } } enum StatRangeMode: String { case calendar, rolling } struct StatsView: View { @EnvironmentObject private var recordStore: RecordStore var onClose: () -> Void @State private var scope: StatScope = .day @State private var weekMode: StatRangeMode = .calendar @State private var monthMode: StatRangeMode = .calendar /// 주 시작 = 월요일 private var calendar: Calendar { var c = Calendar.current c.firstWeekday = 2 return c } var body: some View { ZStack { AppTheme.background.ignoresSafeArea() VStack(spacing: 12) { header Picker("기간", selection: $scope) { ForEach(StatScope.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) .padding(.horizontal, 20) if scope != .day { rangeModePicker } ScrollView { VStack(spacing: 14) { summaryCard chartCard if scope == .day { sessionListCard } } .padding(.horizontal, 20) .padding(.bottom, 24) } } } .gesture( DragGesture(minimumDistance: 40) .onEnded { value in if value.translation.height > 70, abs(value.translation.width) < 100 { onClose() } } ) .onAppear { #if DEBUG if let index = CommandLine.arguments.firstIndex(of: "-statsScope"), index + 1 < CommandLine.arguments.count, let argScope = StatScope.allCases.first(where: { String(describing: $0) == CommandLine.arguments[index + 1] }) { scope = argScope } if CommandLine.arguments.contains("-statsRolling") { weekMode = .rolling monthMode = .rolling } #endif } } // MARK: 헤더·피커 private var header: some View { VStack(spacing: 6) { // 아래로 쓸어내려 닫기 손잡이 Capsule() .fill(.secondary.opacity(0.5)) .frame(width: 36, height: 5) .padding(.top, 8) HStack { Text("통계") .font(.largeTitle.bold()) Spacer() Button(action: onClose) { Image(systemName: "xmark") .font(.headline) .foregroundStyle(.secondary) .frame(width: 36, height: 36) .background(AppTheme.surface, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel(Text("통계 닫기")) } .padding(.horizontal, 20) } } private var rangeModePicker: some View { Picker("범위", selection: scope == .week ? $weekMode : $monthMode) { Text(scope == .week ? "이번 주" : "이번 달").tag(StatRangeMode.calendar) Text(scope == .week ? "지난 7일" : "지난 30일").tag(StatRangeMode.rolling) } .pickerStyle(.segmented) .padding(.horizontal, 20) } // MARK: 데이터 private struct DayCount: Identifiable { let date: Date let sets: Int let reps: Int var id: Date { date } } private struct HourCount: Identifiable { let hour: Int let reps: Int var id: Int { hour } } /// 현재 범위의 날짜(자정) 목록 private var rangeDates: [Date] { let today = calendar.startOfDay(for: Date()) switch scope { case .day: return [today] case .week: if weekMode == .rolling { return (0..<7).reversed().map { calendar.date(byAdding: .day, value: -$0, to: today)! } } let start = calendar.dateInterval(of: .weekOfYear, for: Date())!.start return (0..<7).map { calendar.date(byAdding: .day, value: $0, to: start)! } case .month: if monthMode == .rolling { return (0..<30).reversed().map { calendar.date(byAdding: .day, value: -$0, to: today)! } } let start = calendar.dateInterval(of: .month, for: Date())!.start let count = calendar.range(of: .day, in: .month, for: Date())!.count return (0.. [SessionRecord] { recordStore.records.filter { calendar.startOfDay(for: $0.startedAt) == day } } private var dayCounts: [DayCount] { rangeDates.map { day in let dayRecords = records(on: day) return DayCount(date: day, sets: dayRecords.count, reps: dayRecords.reduce(0) { $0 + $1.reps }) } } private var hourCounts: [HourCount] { let todayRecords = records(on: calendar.startOfDay(for: Date())) var byHour: [Int: Int] = [:] for record in todayRecords { let hour = calendar.component(.hour, from: record.startedAt) byHour[hour, default: 0] += record.reps } return (0..<24).map { HourCount(hour: $0, reps: byHour[$0] ?? 0) } } private var totalSets: Int { dayCounts.reduce(0) { $0 + $1.sets } } private var totalReps: Int { dayCounts.reduce(0) { $0 + $1.reps } } // MARK: 카드 private var summaryCard: some View { HStack(spacing: 0) { summaryItem(value: "\(totalSets)", label: Text("세트")) Divider().frame(height: 36) summaryItem(value: "\(totalReps)", label: Text("횟수")) } .padding(.vertical, 16) .frame(maxWidth: .infinity) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } private func summaryItem(value: String, label: Text) -> some View { VStack(spacing: 4) { Text(value) .font(.title.bold()) .monospacedDigit() .foregroundStyle(AppTheme.green) label .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } private var chartCard: some View { VStack(alignment: .leading, spacing: 12) { Text(scope == .day ? "시간대별 횟수" : "일별 횟수") .font(.headline) if totalSets == 0 { Text("이 기간에는 아직 기록이 없어요.") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 120) } else if scope == .day { dayChart } else { rangeChart } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } private var dayChart: some View { Chart(hourCounts) { item in LineMark( x: .value("시", item.hour), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) if item.reps > 0 { PointMark( x: .value("시", item.hour), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) } } .chartXScale(domain: 0...23) .chartXAxis { AxisMarks(values: [0, 6, 12, 18, 23]) { value in AxisGridLine() AxisValueLabel { if let hour = value.as(Int.self) { Text("\(hour)시") } } } } .frame(height: 200) } private var rangeChart: some View { Chart(dayCounts) { item in LineMark( x: .value("날짜", item.date, unit: .day), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) PointMark( x: .value("날짜", item.date, unit: .day), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) } .chartXAxis { if scope == .week { AxisMarks(values: .stride(by: .day)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true) } } else { AxisMarks(values: .automatic(desiredCount: 6)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.day(), centered: true) } } } .frame(height: 200) } private var sessionListCard: some View { VStack(alignment: .leading, spacing: 12) { Text("오늘 기록") .font(.headline) let todayRecords = records(on: calendar.startOfDay(for: Date())) if todayRecords.isEmpty { Text("오늘은 아직 기록이 없어요.") .font(.subheadline) .foregroundStyle(.secondary) } else { ForEach(todayRecords.sorted { $0.startedAt > $1.startedAt }) { record in HStack { Text(record.startedAt, format: .dateTime.hour().minute()) .font(.body) Spacer() Text("\(record.reps)회") .font(.body.weight(.semibold)) .monospacedDigit() .foregroundStyle(AppTheme.green) } .padding(.vertical, 2) } } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } }