diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 00527a3..95637a0 100644 Binary files a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings index af0e114..663cb93 100644 --- a/myApp/Haru_Danim/IOS/Localizable.xcstrings +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -521,6 +521,9 @@ }, "주 시작 요일" : { + }, + "주 평균" : { + }, "주기마다 목표량 이상을 달성하는 것이 목표예요." : { @@ -623,6 +626,9 @@ }, "하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요." : { + }, + "하루 평균" : { + }, "하루다님" : { "comment" : "앱 이름", @@ -643,6 +649,9 @@ }, "한 줄에 표시할 개수" : { + }, + "합계" : { + }, "해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : { diff --git a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift index 718d0ab..29e5568 100644 --- a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift +++ b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift @@ -4,8 +4,8 @@ // // 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터 // - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교) -// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 -// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 +// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표 +// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표 // import SwiftUI @@ -60,18 +60,33 @@ struct StatsTabView: View { filterChip } - ScrollView { - VStack(alignment: .leading, spacing: 16) { - switch span { - case .day: - dayCharts - case .week: - weekCharts - case .month: - monthCharts + 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 } - .padding() } } .background(AppTheme.background) @@ -360,6 +375,16 @@ struct StatsTabView: View { 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: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대 @@ -372,6 +397,16 @@ struct StatsTabView: View { 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: 행동별 시리즈 계산 @@ -546,7 +581,7 @@ struct StatsTabView: View { } } - // MARK: 합계 막대 카드 (기간 누적 비율 비교 + 합계·하루 평균) + // MARK: 합계 막대 카드 (기간 누적 비율 비교) @ViewBuilder private func totalsBarCard(_ title: String, type: TrackingType) -> some View { @@ -570,34 +605,125 @@ struct StatsTabView: View { } .chartXAxisLabel(type == .time ? "시간(h)" : "횟수") .frame(height: CGFloat(items.count) * 40 + 30) - - let total = items.reduce(0.0) { $0 + $1.value } - let average = total / Double(elapsedDayCount) - HStack(spacing: 0) { - summaryCell(label: "\(span.label) 합계", value: valueLabel(total, type: type)) - Divider().frame(height: 28) - summaryCell( - label: "하루 평균", - value: type == .time - ? Format.durationShort(average) - : String(format: "%.1f회", average) - ) - } - .padding(.top, 4) } } } - private func summaryCell(label: String, value: String) -> some View { - VStack(spacing: 2) { - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - Text(value) - .font(.subheadline.weight(.semibold).monospacedDigit()) - .foregroundStyle(AppTheme.green) + // 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) + } + } + } } - .frame(maxWidth: .infinity) } // MARK: 공통