mycode/myApp/HaruDanim/IOS/Views/StatsTabView.swift
songyc macbook 9c9c24f7df feat(a11y): VoiceOver labels for icon-only buttons + combined action-cell narration
접근성 감사 ③ — 전 화면의 아이콘 전용 버튼과 텍스트 없는 시각
요소에 보이스오버 라벨 부여 (시각 UI 변화 없음):

- 모음 탭: 행동 버튼 셀을 label(이름)+value(측정 중/오늘 누적)로
  한 덩어리 낭독 — 아이콘·타이머가 따로 읽히지 않음. 진행 중 행의
  정지 버튼 "측정 종료".
- 기록 탭: 날짜 이동 화살표(이전/다음 날짜), 내보내기, 기록 필터,
  필터 칩 해제 버튼. 타임테이블의 색 블록·횟수 점은 "행동 이름 +
  시각(구간)" verbatim 라벨로 낭독 가능하게.
- 통계 탭: 기간 이동 화살표(이전/다음 기간), 내보내기, 통계 필터,
  필터 해제.
- 행동/꼬리표/목표 탭: + 추가 버튼(행동/꼬리표/목표 추가), ⋯ 메뉴.
- 일기: 달 이동 화살표(이전/다음 달), 내보내기, ⋯ 메뉴, 기분 타일
  ("오늘 기분"), 할 일 삭제 버튼.
- ⑥ 현재 진행 중 위젯: 종료 버튼 "측정 종료".
- CLAUDE.md §15에 컨벤션 추가: 아이콘 전용 버튼 accessibilityLabel
  필수, 복합 셀은 label+value 한 덩어리, 텍스트 없는 시각 요소는
  verbatim 라벨.

신규 문구 9종 ko/en/ja 번역 + 위젯 카탈로그 '측정 종료' 번역 복사
(전 카탈로그 missing 0/stale 0). Debug·Store 빌드 성공, 접근성
수식어는 시각 렌더링에 영향 없음(스모크 런치 확인). 실기기에서
보이스오버 켜고 핵심 흐름(행동 시작/종료·날짜 이동) 낭독 확인 권장.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 22:24:21 +09:00

919 lines
36 KiB
Swift

//
// StatsTabView.swift
// Haru_Danim
//
// ( ): // + ·
// - : / ( )
// - : ( + ) + + · /
// - : / + + · /
//
import SwiftUI
import SwiftData
import Charts
struct StatsTabView: View {
@Environment(AppRouter.self) private var router
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
// (LocalPrefs )
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
private var allActions: [Action] {
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
}
@State private var span: StatSpan = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "statSpan"),
let span = StatSpan(rawValue: raw) {
return span
}
#endif
return .day
}()
@State private var anchorDayKey: Date = DayMath().dayKey(for: .now)
@State private var showingFilter = false
@State private var showingExport = false
/// " " , .
/// , .
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
private var spanRange: Range<Date> {
let anchor = math.dayRange(forKey: anchorDayKey).lowerBound
switch span {
case .day: return math.dayRange(containing: anchor)
case .week: return math.weekRange(containing: anchor)
case .month: return math.monthRange(containing: anchor)
}
}
var body: some View {
VStack(spacing: 0) {
periodHeader
Picker("기간", selection: $span) {
ForEach(StatSpan.allCases) { span in
Text(span.label).tag(span)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
if isFiltering {
filterChip
}
// (//) DB ( ).
// · (SwiftData ).
StatsChartsView(
span: span,
anchorDayKey: anchorDayKey,
excludedActionIDs: excludedActionIDs,
orderedActions: allActions,
showingExport: $showingExport
)
}
.background(AppTheme.background)
.navigationTitle("통계")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingExport = true
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel(Text("내보내기"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingFilter = true
} label: {
Image(systemName: isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("통계 필터"))
}
}
.sheet(isPresented: $showingFilter) {
RecordFilterSheet(
title: "통계 필터",
tags: tags,
actions: allActions,
excludedActionIDs: $excludedActionIDs
)
}
.onAppear {
consumePendingFilter()
#if DEBUG
// : -excludeActions "," , -statShowFilter YES
if let raw = UserDefaults.standard.string(forKey: "excludeActions") {
let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) })
excludedActionIDs = Set(
allActions.filter { names.contains($0.name) }.map(\.persistentModelID)
)
}
if UserDefaults.standard.bool(forKey: "statShowFilter") {
showingFilter = true
}
// : -showExport YES
if UserDefaults.standard.bool(forKey: "showExport") {
showingExport = true
}
#endif
}
.onChange(of: router.pendingStatsActionID) {
consumePendingFilter()
}
}
/// ' ' ( )
private func consumePendingFilter() {
guard let id = router.pendingStatsActionID else { return }
//
excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id])
anchorDayKey = math.dayKey(for: .now)
router.pendingStatsActionID = nil
}
// MARK:
private var periodHeader: some View {
HStack {
Button {
movePeriod(-1)
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.accessibilityLabel(Text("이전 기간"))
Spacer()
VStack(spacing: 1) {
Text(periodLabel)
.font(.headline)
if spanRange.contains(.now) {
Text(currentPeriodLabel)
.font(.caption2)
.foregroundStyle(AppTheme.green)
}
}
Spacer()
Button {
movePeriod(1)
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.accessibilityLabel(Text("다음 기간"))
Button(currentPeriodLabel) {
anchorDayKey = math.dayKey(for: .now)
}
.font(.caption)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal)
.padding(.vertical, 10)
.tint(AppTheme.green)
}
private var currentPeriodLabel: String {
switch span {
case .day: return String(localized: "오늘")
case .week: return String(localized: "이번 주")
case .month: return String(localized: "이번 달")
}
}
private var periodLabel: String {
let keys = math.dayKeys(in: spanRange)
switch span {
case .day:
return Format.fullDate(anchorDayKey)
case .week:
guard let first = keys.first, let last = keys.last else { return "" }
return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))"
case .month:
return anchorDayKey.formatted(.dateTime.year().month())
}
}
private func movePeriod(_ delta: Int) {
let cal = math.calendar
switch span {
case .day:
anchorDayKey = cal.date(byAdding: .day, value: delta, to: anchorDayKey)!
case .week:
anchorDayKey = cal.date(byAdding: .day, value: 7 * delta, to: anchorDayKey)!
case .month:
anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)!
}
}
// MARK: ( , )
private var isFiltering: Bool {
!excludedActionIDs.isEmpty
}
/// : ( )
private func passesFilter(_ action: Action) -> Bool {
!excludedActionIDs.contains(action.persistentModelID)
}
private var filteredActions: [Action] {
allActions.filter(passesFilter)
}
private var filterChip: some View {
HStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease")
.font(.caption2.weight(.semibold))
Text("행동 \(filteredActions.count)/\(allActions.count)개 표시 중")
.font(.caption.weight(.semibold))
.lineLimit(1)
Button {
withAnimation {
excludedActionIDs = []
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("필터 해제"))
}
.foregroundStyle(AppTheme.green)
.padding(.leading, 10)
.padding(.trailing, 2)
.padding(.vertical, 4)
.background(AppTheme.green.opacity(0.12), in: Capsule())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 6)
}
}
// MARK: - ( DB + 1 )
/// . :
/// 1) ** **: (//) @Query
/// · " < AND ( OR > )"
/// . .
/// 2) **1 **: (··) Aggregator ,
/// · .
/// DayMath.splitByDay Aggregator .
private struct StatsChartsView: View {
let span: StatSpan
let anchorDayKey: Date
let excludedActionIDs: Set<PersistentIdentifier>
let orderedActions: [Action]
@Binding var showingExport: Bool
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
private let math = DayMath()
init(span: StatSpan, anchorDayKey: Date,
excludedActionIDs: Set<PersistentIdentifier>, orderedActions: [Action],
showingExport: Binding<Bool>) {
self.span = span
self.anchorDayKey = anchorDayKey
self.excludedActionIDs = excludedActionIDs
self.orderedActions = orderedActions
self._showingExport = showingExport
let math = DayMath()
let anchor = math.dayRange(forKey: anchorDayKey).lowerBound
let range: Range<Date>
switch span {
case .day: range = math.dayRange(containing: anchor)
case .week: range = math.weekRange(containing: anchor)
case .month: range = math.monthRange(containing: anchor)
}
let lower = range.lowerBound
let upper = range.upperBound
let farFuture = Date.distantFuture
_sessions = Query(filter: #Predicate<TimeSession> {
$0.startAt < upper && ($0.endAt ?? farFuture) > lower
})
_entries = Query(filter: #Predicate<CountEntry> {
$0.timestamp >= lower && $0.timestamp < upper
})
}
private var spanRange: Range<Date> {
let anchor = math.dayRange(forKey: anchorDayKey).lowerBound
switch span {
case .day: return math.dayRange(containing: anchor)
case .week: return math.weekRange(containing: anchor)
case .month: return math.monthRange(containing: anchor)
}
}
var body: some View {
let data = makeStatsData()
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
switch span {
case .day:
dayCharts(data)
case .week:
weekCharts(data)
case .month:
monthCharts(data)
}
}
.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
}
}
.sheet(isPresented: $showingExport) {
// /
ExportImageSheet(snapshot: ExportBuilder.stats(
span: span,
anchorDayKey: anchorDayKey,
sessions: sessions,
entries: entries,
orderedActions: orderedActions,
excludedActionIDs: excludedActionIDs,
math: math
))
}
}
// MARK:
private func passesFilter(_ action: Action) -> Bool {
!excludedActionIDs.contains(action.persistentModelID)
}
private var filteredActions: [Action] {
orderedActions.filter(passesFilter)
}
// MARK: 1
/// · ( )
private struct StatsData {
var seconds: [PersistentIdentifier: [Date: TimeInterval]] = [:]
var counts: [PersistentIdentifier: [Date: Int]] = [:]
///
var dayKeys: [Date] = []
func seconds(for id: PersistentIdentifier, days: [Date]) -> TimeInterval {
guard let byDay = seconds[id] else { return 0 }
return days.reduce(0) { $0 + (byDay[$1] ?? 0) }
}
func count(for id: PersistentIdentifier, days: [Date]) -> Int {
guard let byDay = counts[id] else { return 0 }
return days.reduce(0) { $0 + (byDay[$1] ?? 0) }
}
/// (=, =)
func value(for action: Action, type: TrackingType, days: [Date]) -> Double {
switch type {
case .time: return seconds(for: action.persistentModelID, days: days)
case .count: return Double(count(for: action.persistentModelID, days: days))
}
}
}
private func makeStatsData() -> StatsData {
var data = StatsData()
let range = spanRange
data.dayKeys = math.dayKeys(in: range)
let now = Date.now
for session in sessions {
guard let action = session.action, passesFilter(action) else { continue }
let start = max(session.startAt, range.lowerBound)
let end = min(session.endAt ?? now, range.upperBound)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end) {
data.seconds[action.persistentModelID, default: [:]][segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
}
}
for entry in entries {
guard range.contains(entry.timestamp),
let action = entry.action, passesFilter(action) else { continue }
data.counts[action.persistentModelID, default: [:]][math.dayKey(for: entry.timestamp), default: 0]
+= entry.amount
}
return data
}
/// : " " ( )
private var elapsedDayCount: Int {
max(
math.dayKeys(in: spanRange)
.filter { math.dayRange(forKey: $0).lowerBound <= .now }
.count,
1
)
}
// MARK: ( )
@ViewBuilder
private func dayCharts(_ data: StatsData) -> some View {
let stats = tagStats(data)
tagTimeBarCard(stats)
tagCountBarCard(stats)
}
/// ( )
private func tagStats(_ data: StatsData) -> [TagStat] {
var seconds: [String: TimeInterval] = [:]
var countsByTag: [String: Int] = [:]
var colors: [String: Color] = [:]
func tagNames(for action: Action) -> [(String, Color)] {
if action.tags.isEmpty { return [(String(localized: "꼬리표 없음"), Color.gray)] }
return action.sortedTags.map { ($0.name, $0.color) }
}
for action in filteredActions {
let totalSeconds = data.seconds(for: action.persistentModelID, days: data.dayKeys)
let totalCount = data.count(for: action.persistentModelID, days: data.dayKeys)
guard totalSeconds > 0 || totalCount > 0 else { continue }
for (name, color) in tagNames(for: action) {
seconds[name, default: 0] += totalSeconds
countsByTag[name, default: 0] += totalCount
colors[name] = color
}
}
let names = Set(seconds.keys).union(countsByTag.keys)
return names
.map { name in
TagStat(
name: name,
color: colors[name] ?? .gray,
seconds: seconds[name] ?? 0,
count: countsByTag[name] ?? 0
)
}
.sorted { $0.seconds > $1.seconds }
}
@ViewBuilder
private func tagTimeBarCard(_ stats: [TagStat]) -> some View {
let timeStats = stats.filter { $0.seconds > 0 }
chartCard("꼬리표별 시간 비교") {
if timeStats.isEmpty {
emptyChartText
} else {
Chart(timeStats) { stat in
BarMark(
x: .value("시간", stat.seconds / 3600),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text(Format.durationShort(stat.seconds))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("시간(h)")
.frame(height: CGFloat(timeStats.count) * 44 + 30)
}
}
}
@ViewBuilder
private func tagCountBarCard(_ stats: [TagStat]) -> some View {
let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count }
chartCard("꼬리표별 횟수 비교") {
if countStats.isEmpty {
emptyChartText
} else {
Chart(countStats) { stat in
BarMark(
x: .value("횟수", stat.count),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text("\(stat.count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("횟수")
.frame(height: CGFloat(countStats.count) * 44 + 30)
}
}
}
// MARK: +
@ViewBuilder
private func weekCharts(_ data: StatsData) -> some View {
dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true, data: data)
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true, data: data)
totalsBarCard("주간 시간 합계", type: .time, data: data)
totalsBarCard("주간 횟수 합계", type: .count, data: data)
summaryTableCard(
"행동별 합계·평균",
timeRows: actionSummaryRows(for: .time, data: data),
countRows: actionSummaryRows(for: .count, data: data)
)
summaryTableCard(
"꼬리표별 합계·평균",
timeRows: tagSummaryRows(for: .time, data: data),
countRows: tagSummaryRows(for: .count, data: data)
)
}
// MARK: / +
@ViewBuilder
private func monthCharts(_ data: StatsData) -> some View {
weeklyLineCard("행동별 시간 (주차별)", type: .time, data: data)
weeklyLineCard("행동별 횟수 (주차별)", type: .count, data: data)
dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false, data: data)
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false, data: data)
totalsBarCard("월간 시간 합계", type: .time, data: data)
totalsBarCard("월간 횟수 합계", type: .count, data: data)
summaryTableCard(
"행동별 합계·평균",
timeRows: actionSummaryRows(for: .time, data: data),
countRows: actionSummaryRows(for: .count, data: data)
)
summaryTableCard(
"꼬리표별 합계·평균",
timeRows: tagSummaryRows(for: .time, data: data),
countRows: tagSummaryRows(for: .count, data: data)
)
}
// MARK:
private struct ActionDayPoint: Identifiable {
let dayKey: Date
let actionName: String
let value: Double
var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(actionName)" }
}
private struct ActionWeekPoint: Identifiable {
let weekLabel: String
let actionName: String
let value: Double
var id: String { "\(weekLabel)-\(actionName)" }
}
private struct ActionTotal: Identifiable {
let name: String
let color: Color
let value: Double
var id: String { name }
}
/// (0 ) /
private func activeActions(for type: TrackingType, data: StatsData) -> [Action] {
filteredActions
.filter { $0.trackingType == type }
.filter { data.value(for: $0, type: type, days: data.dayKeys) > 0 }
}
private func dailyPoints(for actions: [Action], type: TrackingType, data: StatsData) -> [ActionDayPoint] {
var result: [ActionDayPoint] = []
for key in data.dayKeys {
for action in actions {
let raw = data.value(for: action, type: type, days: [key])
result.append(ActionDayPoint(
dayKey: key, actionName: action.name,
value: type == .time ? raw / 3600 : raw
))
}
}
return result
}
/// ( , )
private var weekRangesInMonth: [Range<Date>] {
let range = spanRange
var result: [Range<Date>] = []
var cursor = range.lowerBound
while cursor < range.upperBound {
let week = math.weekRange(containing: cursor)
result.append(max(week.lowerBound, range.lowerBound)..<min(week.upperBound, range.upperBound))
cursor = week.upperBound
}
return result
}
private func weeklyPoints(for actions: [Action], type: TrackingType, data: StatsData) -> [ActionWeekPoint] {
var result: [ActionWeekPoint] = []
for (index, weekRange) in weekRangesInMonth.enumerated() {
// =
let weekDays = math.dayKeys(in: weekRange)
for action in actions {
let raw = data.value(for: action, type: type, days: weekDays)
// String(localized:) en/ja ( ExportBuilder )
result.append(ActionWeekPoint(weekLabel: String(localized: "\(index + 1)주차"),
actionName: action.name,
value: type == .time ? raw / 3600 : raw))
}
}
return result
}
private func totals(for type: TrackingType, data: StatsData) -> [ActionTotal] {
activeActions(for: type, data: data)
.map { action in
ActionTotal(name: action.name, color: action.color,
value: data.value(for: action, type: type, days: data.dayKeys))
}
.sorted { $0.value > $1.value }
}
private func valueLabel(_ value: Double, type: TrackingType) -> String {
switch type {
case .time: return Format.durationShort(value)
case .count: return String(localized: "\(Int(value.rounded()))")
}
}
// MARK: ( = / , )
@ViewBuilder
private func dailyLineCard(_ title: LocalizedStringKey, type: TrackingType, weekdayAxis: Bool,
data: StatsData) -> some View {
let actions = activeActions(for: type, data: data)
let names = actions.map(\.name)
let colors = actions.map(\.color)
chartCard(title) {
if actions.isEmpty {
emptyChartText
} else {
Chart(dailyPoints(for: actions, type: type, data: data)) { point in
LineMark(
x: .value("날짜", point.dayKey, unit: .day),
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
)
.foregroundStyle(by: .value("행동", point.actionName))
.symbol(by: .value("행동", point.actionName))
.interpolationMethod(.monotone)
}
.chartForegroundStyleScale(domain: names, range: colors)
.chartXAxis {
if weekdayAxis {
AxisMarks(values: .stride(by: .day)) { value in
AxisGridLine()
AxisValueLabel {
if let date = value.as(Date.self) {
Text(Format.weekdayShort(math.calendar.component(.weekday, from: date)))
}
}
}
} else {
AxisMarks { _ in
AxisGridLine()
AxisValueLabel(format: .dateTime.day())
}
}
}
.chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
.frame(height: 200)
}
}
}
@ViewBuilder
private func weeklyLineCard(_ title: LocalizedStringKey, type: TrackingType,
data: StatsData) -> some View {
let actions = activeActions(for: type, data: data)
let names = actions.map(\.name)
let colors = actions.map(\.color)
chartCard(title) {
if actions.isEmpty {
emptyChartText
} else {
Chart(weeklyPoints(for: actions, type: type, data: data)) { point in
LineMark(
x: .value("주차", point.weekLabel),
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
)
.foregroundStyle(by: .value("행동", point.actionName))
.symbol(by: .value("행동", point.actionName))
.interpolationMethod(.monotone)
}
.chartForegroundStyleScale(domain: names, range: colors)
.chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
.frame(height: 200)
}
}
}
// MARK: ( )
@ViewBuilder
private func totalsBarCard(_ title: LocalizedStringKey, type: TrackingType,
data: StatsData) -> some View {
let items = totals(for: type, data: data)
chartCard(title) {
if items.isEmpty {
emptyChartText
} else {
Chart(items) { item in
BarMark(
x: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), type == .time ? item.value / 3600 : item.value),
y: .value("행동", item.name)
)
.foregroundStyle(item.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text(valueLabel(item.value, type: type))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
.frame(height: CGFloat(items.count) * 40 + 30)
}
}
}
// 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, data: StatsData) -> [SummaryRowItem] {
activeActions(for: type, data: data)
.map { action in
SummaryRowItem(name: action.name, color: action.color, symbol: action.symbolName,
total: data.value(for: action, type: type, days: data.dayKeys),
type: type)
}
.sorted { $0.total > $1.total }
}
private func tagSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] {
let stats = tagStats(data)
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 Format.countAverage(value)
}
}
@ViewBuilder
private func summaryTableCard(_ title: LocalizedStringKey, 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: LocalizedStringKey, 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)
}
}
}
}
}
// MARK:
private func chartCard(_ title: LocalizedStringKey, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline.weight(.semibold))
content()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private var emptyChartText: some View {
Text("표시할 기록이 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
}
// MARK: -
struct TagStat: Identifiable {
let name: String
let color: Color
let seconds: TimeInterval
let count: Int
var id: String { name }
}