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" : "앱 이름",
|
"comment" : "앱 이름",
|
||||||
@ -643,6 +649,9 @@
|
|||||||
},
|
},
|
||||||
"한 줄에 표시할 개수" : {
|
"한 줄에 표시할 개수" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"합계" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : {
|
"해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : {
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
//
|
//
|
||||||
// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터
|
// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터
|
||||||
// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교)
|
// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교)
|
||||||
// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대
|
// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
||||||
// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대
|
// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -60,18 +60,33 @@ struct StatsTabView: View {
|
|||||||
filterChip
|
filterChip
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
ScrollViewReader { proxy in
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
ScrollView {
|
||||||
switch span {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
case .day:
|
switch span {
|
||||||
dayCharts
|
case .day:
|
||||||
case .week:
|
dayCharts
|
||||||
weekCharts
|
case .week:
|
||||||
case .month:
|
weekCharts
|
||||||
monthCharts
|
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)
|
.background(AppTheme.background)
|
||||||
@ -360,6 +375,16 @@ struct StatsTabView: View {
|
|||||||
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true)
|
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true)
|
||||||
totalsBarCard("주간 시간 합계", type: .time)
|
totalsBarCard("주간 시간 합계", type: .time)
|
||||||
totalsBarCard("주간 횟수 합계", type: .count)
|
totalsBarCard("주간 횟수 합계", type: .count)
|
||||||
|
summaryTableCard(
|
||||||
|
"행동별 합계·평균",
|
||||||
|
timeRows: actionSummaryRows(for: .time),
|
||||||
|
countRows: actionSummaryRows(for: .count)
|
||||||
|
)
|
||||||
|
summaryTableCard(
|
||||||
|
"꼬리표별 합계·평균",
|
||||||
|
timeRows: tagSummaryRows(for: .time),
|
||||||
|
countRows: tagSummaryRows(for: .count)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대
|
// MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대
|
||||||
@ -372,6 +397,16 @@ struct StatsTabView: View {
|
|||||||
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false)
|
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false)
|
||||||
totalsBarCard("월간 시간 합계", type: .time)
|
totalsBarCard("월간 시간 합계", type: .time)
|
||||||
totalsBarCard("월간 횟수 합계", type: .count)
|
totalsBarCard("월간 횟수 합계", type: .count)
|
||||||
|
summaryTableCard(
|
||||||
|
"행동별 합계·평균",
|
||||||
|
timeRows: actionSummaryRows(for: .time),
|
||||||
|
countRows: actionSummaryRows(for: .count)
|
||||||
|
)
|
||||||
|
summaryTableCard(
|
||||||
|
"꼬리표별 합계·평균",
|
||||||
|
timeRows: tagSummaryRows(for: .time),
|
||||||
|
countRows: tagSummaryRows(for: .count)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 행동별 시리즈 계산
|
// MARK: 행동별 시리즈 계산
|
||||||
@ -546,7 +581,7 @@ struct StatsTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: 합계 막대 카드 (기간 누적 비율 비교 + 합계·하루 평균)
|
// MARK: 합계 막대 카드 (기간 누적 비율 비교)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func totalsBarCard(_ title: String, type: TrackingType) -> some View {
|
private func totalsBarCard(_ title: String, type: TrackingType) -> some View {
|
||||||
@ -570,34 +605,125 @@ struct StatsTabView: View {
|
|||||||
}
|
}
|
||||||
.chartXAxisLabel(type == .time ? "시간(h)" : "횟수")
|
.chartXAxisLabel(type == .time ? "시간(h)" : "횟수")
|
||||||
.frame(height: CGFloat(items.count) * 40 + 30)
|
.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 {
|
// MARK: 행동·꼬리표별 합계/평균 요약 표 (주간/월간, 필터 반영)
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(label)
|
private struct SummaryRowItem: Identifiable {
|
||||||
.font(.caption2)
|
let name: String
|
||||||
.foregroundStyle(.secondary)
|
let color: Color
|
||||||
Text(value)
|
let symbol: String
|
||||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
/// 시간형은 초, 횟수형은 횟수
|
||||||
.foregroundStyle(AppTheme.green)
|
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: 공통
|
// MARK: 공통
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user