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:
songyc macbook 2026-07-09 15:53:25 +09:00
parent acdddbb670
commit ad9af22e9d
3 changed files with 171 additions and 36 deletions

View File

@ -521,6 +521,9 @@
},
"주 시작 요일" : {
},
"주 평균" : {
},
"주기마다 목표량 이상을 달성하는 것이 목표예요." : {
@ -623,6 +626,9 @@
},
"하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요." : {
},
"하루 평균" : {
},
"하루다님" : {
"comment" : "앱 이름",
@ -643,6 +649,9 @@
},
"한 줄에 표시할 개수" : {
},
"합계" : {
},
"해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : {

View File

@ -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: