feat(stats): replace general averages with per-action and per-tag metrics
- Remove unnecessary overall totals and general average metrics below weekly and monthly bar charts - Add detailed summary sections displaying the sum and average calculated for each specific action and tag - Ensure the new summary metrics dynamically update based on the selected filters
This commit is contained in:
parent
acdddbb670
commit
ad9af22e9d
Binary file not shown.
@ -521,6 +521,9 @@
|
||||
},
|
||||
"주 시작 요일" : {
|
||||
|
||||
},
|
||||
"주 평균" : {
|
||||
|
||||
},
|
||||
"주기마다 목표량 이상을 달성하는 것이 목표예요." : {
|
||||
|
||||
@ -623,6 +626,9 @@
|
||||
},
|
||||
"하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요." : {
|
||||
|
||||
},
|
||||
"하루 평균" : {
|
||||
|
||||
},
|
||||
"하루다님" : {
|
||||
"comment" : "앱 이름",
|
||||
@ -643,6 +649,9 @@
|
||||
},
|
||||
"한 줄에 표시할 개수" : {
|
||||
|
||||
},
|
||||
"합계" : {
|
||||
|
||||
},
|
||||
"해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : {
|
||||
|
||||
|
||||
@ -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: 공통
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user