mycode/myApp/HaruDanim/IOS/Views/StatsTabView.swift
songyc macbook 0d78789a05 feat(1.5-p14): 통계·기록 탭 건강 데이터 — 추이·합계 카드와 그날 값 칩, 빌드 10
사용자 요구('이거까지 하고 배포'): 통계·기록에서도 건강 데이터를 하루다님 스타일로.
전부 표시 전용 — HealthCache 인메모리 읽기만, HK 신규 조회·스키마·집계 수학 무변경.

- 통계 탭 맨 아래: 하루=지표 값 칩 카드 / 주간·월간(롤링 포함)=
  '건강 지표 (일별)' 추이 카드(지표 선택 캡슐 메뉴·요일/일 축·단위 라벨, 분홍 단일 선)
  + '건강 지표 합계·평균' 표(분모=기존 '이미 시작된 날/주' 규칙 상속, 0 지표 생략)
- 기록 탭 목록 맨 아래 '건강 데이터' 섹션 — 그날 칩(과거 날짜도 캐시로)
- 표시 규칙: 지표=타일 선택 상속, 마스터 스위치=설정 '건강 데이터 표시'(문구 확장),
  미연결·맥=숨김, 내보내기 미포함(비교 카드 전례)
- 값 공급 단일 지점 HealthDisplayValues 신설(수면=유효 표시 구간 키, 일기 카드도 위임),
  칩 UI HealthValueChipGrid 공용 — 빌드 9 최적화 그대로 유지
- 도움말·팝업·설정 푸터·whats-new 3언어 갱신(새 키 5·수정 3, 전 카탈로그 0/0)

검증: 자가 검증 62/20/27/10 ALL PASS(수치 불변), 시각 QA(하루·주간[합계 53,966보 시드
검산 일치]·월간 롤링[30일 분모]·기록 목록 과거 날짜·ja·18.5[경과일 분모 4일 검산 일치]),
Debug·Store·18.5 빌드 그린. 종목별 운동 통계는 1.6 후보 보류. CURRENT_PROJECT_VERSION 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-27 19:28:20 +09:00

1344 lines
56 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// StatsTabView.swift
// Haru_Danim
//
// ( ): // + ·
// - : / ( )
// - : ( + ) + + · /
// - : / + + · /
//
import SwiftUI
import SwiftData
import Charts
/// (1.4 ' ' ).
/// StatSpan enum ( ··)
/// ·rolling .
func statsRange(span: StatSpan, anchorDayKey: Date, rolling: Bool, math: DayMath) -> Range<Date> {
let anchor = math.dayRange(forKey: anchorDayKey).lowerBound
switch span {
case .day:
return math.dayRange(containing: anchor)
case .week:
return rolling
? math.rollingRange(endingAtKey: anchorDayKey, days: 7)
: math.weekRange(containing: anchor)
case .month:
return rolling
? math.rollingRange(endingAtKey: anchorDayKey, days: 30)
: math.monthRange(containing: anchor)
}
}
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
/// ' ' ( 7, 30).
@AppStorage(SettingsKeys.statsRollingWindow) private var rollingWindow = false
/// " " , .
/// , .
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
/// span
private var isRolling: Bool { rollingWindow && span != .day }
private var spanRange: Range<Date> {
statsRange(span: span, anchorDayKey: anchorDayKey, rolling: isRolling, math: math)
}
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)
// / ' ' (1.4) ·
// .
if span != .day {
Toggle(isOn: $rollingWindow.animation()) {
Text(span == .week ? "지난 7일 단위로 보기" : "지난 30일 단위로 보기")
.font(.caption.weight(.semibold))
}
.toggleStyle(.switch)
.controlSize(.mini)
.tint(AppTheme.green)
.padding(.horizontal)
.padding(.bottom, 8)
}
if isFiltering {
filterChip
}
// (//) DB ( ).
// · (SwiftData ).
StatsChartsView(
span: span,
anchorDayKey: anchorDayKey,
rolling: isRolling,
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
}
// : -statRolling YES ' '
if UserDefaults.standard.bool(forKey: "statRolling") {
rollingWindow = 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 isRolling ? String(localized: "최근 7일") : String(localized: "이번 주")
case .month: return isRolling ? String(localized: "최근 30일") : 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:
if isRolling {
guard let first = keys.first, let last = keys.last else { return "" }
return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))"
}
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:
// ( 30) 30,
if isRolling {
anchorDayKey = cal.date(byAdding: .day, value: 30 * delta, to: anchorDayKey)!
} else {
anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)!
}
}
}
// MARK: ( , )
private var isFiltering: Bool {
// ID
// ( · )
allActions.contains { !passesFilter($0) }
}
/// : ( )
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
/// ' ' ( 7, 30 span false)
let rolling: Bool
let excludedActionIDs: Set<PersistentIdentifier>
let orderedActions: [Action]
@Binding var showingExport: Bool
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
// 1.5(10): · ( ).
// ' '( )
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var healthMetricsRaw = ""
/// ( statsRollingWindow )
@AppStorage(SettingsKeys.statsHealthMetric) private var statsHealthMetricRaw = ""
private let math = DayMath()
init(span: StatSpan, anchorDayKey: Date, rolling: Bool,
excludedActionIDs: Set<PersistentIdentifier>, orderedActions: [Action],
showingExport: Binding<Bool>) {
self.span = span
self.anchorDayKey = anchorDayKey
self.rolling = rolling
self.excludedActionIDs = excludedActionIDs
self.orderedActions = orderedActions
self._showingExport = showingExport
let math = DayMath()
let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math)
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> {
statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math)
}
// MARK: ( , §6.6)
/// ( 7/30 )
private var previousRange: Range<Date> {
if rolling {
let days = span == .week ? 7 : 30
let prevEndKey = math.calendar.date(byAdding: .day, value: -days, to: anchorDayKey)
?? anchorDayKey
return math.rollingRange(endingAtKey: prevEndKey, days: days)
}
let justBefore = spanRange.lowerBound.addingTimeInterval(-1)
switch span {
case .day: return math.dayRange(containing: justBefore)
case .week: return math.weekRange(containing: justBefore)
case .month: return math.monthRange(containing: justBefore)
}
}
/// . @Query
/// Aggregator ( )
private func periodTotals(in range: Range<Date>) -> (seconds: TimeInterval, count: Int) {
let agg = Aggregator(math: math)
var seconds: TimeInterval = 0
var count = 0
for action in filteredActions {
switch action.trackingType {
case .time: seconds += agg.seconds(for: action, in: range)
case .count: count += agg.count(for: action, in: range)
}
}
return (seconds, count)
}
private var previousPeriodTitle: LocalizedStringKey {
if rolling {
return span == .week ? "이전 7일과 비교" : "이전 30일과 비교"
}
switch span {
case .day: return "어제와 비교"
case .week: return "지난주와 비교"
case .month: return "지난달과 비교"
}
}
/// · 0 ,
///
@ViewBuilder
private var comparisonCard: some View {
let current = periodTotals(in: spanRange)
let previous = periodTotals(in: previousRange)
let showTime = current.seconds > 0 || previous.seconds > 0
let showCount = current.count > 0 || previous.count > 0
if showTime || showCount {
VStack(alignment: .leading, spacing: 10) {
Text(previousPeriodTitle)
.font(.subheadline.weight(.semibold))
if showTime {
comparisonRow(
symbol: "timer",
title: String(localized: "시간"),
currentLabel: Format.durationShort(current.seconds),
previousLabel: Format.durationShort(previous.seconds),
current: current.seconds,
previous: previous.seconds
)
}
if showCount {
comparisonRow(
symbol: "number",
title: String(localized: "횟수"),
currentLabel: String(localized: "\(current.count)"),
previousLabel: String(localized: "\(previous.count)"),
current: Double(current.count),
previous: Double(previous.count)
)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private func comparisonRow(symbol: String, title: String,
currentLabel: String, previousLabel: String,
current: Double, previous: Double) -> some View {
HStack(spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.green)
.frame(width: 18)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
VStack(alignment: .trailing, spacing: 1) {
HStack(spacing: 6) {
Text(currentLabel)
.font(.footnote.weight(.semibold).monospacedDigit())
deltaBadge(current: current, previous: previous)
}
Text("이전 \(previousLabel)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
/// (0 )
@ViewBuilder
private func deltaBadge(current: Double, previous: Double) -> some View {
if previous <= 0 {
Text("이전 기록 없음")
.font(.caption2)
.foregroundStyle(.tertiary)
} else {
let percent = Int(((current - previous) / previous * 100).rounded())
Group {
if percent > 0 {
Text(verbatim: "") + Text("\(percent)%")
} else if percent < 0 {
Text(verbatim: "") + Text("\(abs(percent))%")
} else {
Text("변화 없음")
}
}
.font(.caption2.weight(.bold).monospacedDigit())
.foregroundStyle(percent > 0 ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary))
}
}
var body: some View {
let data = makeStatsData()
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
comparisonCard
switch span {
case .day:
dayCharts(data)
case .week:
weekCharts(data)
case .month:
monthCharts(data)
}
// (1.5(10) )
healthSection
}
.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,
rolling: rolling,
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)
}
/// ( ).
/// identity(' ' nil) ,
/// Format.disambiguated
private func tagStats(_ data: StatsData) -> [TagStat] {
struct Acc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 }
var accs: [PersistentIdentifier?: Acc] = [:]
var order: [PersistentIdentifier?] = [] // ( )
func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] {
if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] }
return action.sortedTags.map { ($0.persistentModelID, $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 (key, name, color) in tagKeys(for: action) {
if accs[key] == nil {
accs[key] = Acc(name: name, color: color)
order.append(key)
}
accs[key]?.seconds += totalSeconds
accs[key]?.count += totalCount
}
}
let ordered = order.compactMap { accs[$0] }
let names = Format.disambiguated(ordered.map(\.name))
return zip(ordered, names)
.map { acc, name in
TagStat(name: name, color: acc.color, seconds: acc.seconds, count: acc.count)
}
.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: +
/// " 7/30", "/"
private var totalsTimeTitle: LocalizedStringKey {
if rolling { return span == .week ? "지난 7일 시간 합계" : "지난 30일 시간 합계" }
return span == .week ? "주간 시간 합계" : "월간 시간 합계"
}
private var totalsCountTitle: LocalizedStringKey {
if rolling { return span == .week ? "지난 7일 횟수 합계" : "지난 30일 횟수 합계" }
return span == .week ? "주간 횟수 합계" : "월간 횟수 합계"
}
@ViewBuilder
private func weekCharts(_ data: StatsData) -> some View {
dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true, data: data)
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true, data: data)
totalsBarCard(totalsTimeTitle, type: .time, data: data)
totalsBarCard(totalsCountTitle, 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 {
// ( 30) "N"
// + + ( )
if !rolling {
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(totalsTimeTitle, type: .time, data: data)
totalsBarCard(totalsCountTitle, 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], names: [String], type: TrackingType, data: StatsData) -> [ActionDayPoint] {
var result: [ActionDayPoint] = []
let series = Array(zip(actions, names))
for key in data.dayKeys {
for (action, name) in series {
let raw = data.value(for: action, type: type, days: [key])
result.append(ActionDayPoint(
dayKey: key, actionName: 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], names: [String], type: TrackingType, data: StatsData) -> [ActionWeekPoint] {
var result: [ActionWeekPoint] = []
let series = Array(zip(actions, names))
for (index, weekRange) in weekRangesInMonth.enumerated() {
// =
let weekDays = math.dayKeys(in: weekRange)
for (action, name) in series {
let raw = data.value(for: action, type: type, days: weekDays)
// String(localized:) en/ja ( ExportBuilder )
result.append(ActionWeekPoint(weekLabel: String(localized: "\(index + 1)주차"),
actionName: name,
value: type == .time ? raw / 3600 : raw))
}
}
return result
}
private func totals(for type: TrackingType, data: StatsData) -> [ActionTotal] {
let actions = activeActions(for: type, data: data)
let names = Format.disambiguated(actions.map(\.name))
return zip(actions, names)
.map { action, name in
ActionTotal(name: 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 = Format.disambiguated(actions.map(\.name))
let colors = actions.map(\.color)
chartCard(title) {
if actions.isEmpty {
emptyChartText
} else {
Chart(dailyPoints(for: actions, names: names, 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 = Format.disambiguated(actions.map(\.name))
let colors = actions.map(\.color)
chartCard(title) {
if actions.isEmpty {
emptyChartText
} else {
Chart(weeklyPoints(for: actions, names: names, 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] {
let actions = activeActions(for: type, data: data)
let names = Format.disambiguated(actions.map(\.name))
return zip(actions, names)
.map { action, name in
SummaryRowItem(name: 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 && !rolling {
Text("주 평균")
.gridColumnAlignment(.trailing)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
ForEach(rows) { row in
GridRow {
HStack(spacing: 8) {
Image(safeSymbol: 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 && !rolling {
Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type))
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: (1.5(10) HealthCache .
// ' ' )
/// + ' ' + .
/// ( CTA )
private var showsHealth: Bool {
HealthDataStore.isAvailable && healthTilesEnabled && HealthDataStore.shared.hasRequestedAuth
}
/// = ( )
private var healthMetricsList: [HealthMetric] {
HealthMetric.selectedList(raw: healthMetricsRaw)
}
/// ( )
private var chartHealthMetric: HealthMetric {
if let saved = HealthMetric(rawValue: statsHealthMetricRaw),
healthMetricsList.contains(saved) {
return saved
}
return healthMetricsList.first ?? .steps
}
@ViewBuilder
private var healthSection: some View {
if showsHealth {
if span == .day {
// : ( )
chartCard("건강 데이터") {
HealthValueChipGrid(dayKey: anchorDayKey, metrics: healthMetricsList)
}
} else {
let days = math.dayKeys(in: spanRange)
let hasAny = days.contains { day in
healthMetricsList.contains { HealthDisplayValues.value($0, dayKey: day) != nil }
}
if hasAny {
healthTrendCard(days: days)
healthSummaryCard(days: days)
} else {
// (400)
chartCard("건강 데이터") { emptyHealthText }
}
}
}
}
private struct HealthDayPoint: Identifiable {
let dayKey: Date
let value: Double
var id: Date { dayKey }
}
/// (h), · km·L,
private func healthChartValue(_ metric: HealthMetric, _ raw: Double) -> Double {
if metric.isDuration { return raw / 3600 }
switch metric {
case .distance, .water: return raw / 1000
default: return raw
}
}
/// Y (kcal·km·L )
@ViewBuilder
private func healthUnitLabel(_ metric: HealthMetric) -> some View {
if metric.isDuration || metric == .standHours {
Text("시간(h)")
} else {
switch metric {
case .steps: Text("")
case .activeEnergy: Text(verbatim: "kcal")
case .distance: Text(verbatim: "km")
case .water: Text(verbatim: "L")
default: Text(verbatim: "")
}
}
}
/// ,
private func healthTrendCard(days: [Date]) -> some View {
let metric = chartHealthMetric
let points = days.map { day in
HealthDayPoint(
dayKey: day,
value: healthChartValue(metric, HealthDisplayValues.value(metric, dayKey: day) ?? 0)
)
}
return VStack(alignment: .leading, spacing: 12) {
HStack {
Text("건강 지표 (일별)")
.font(.subheadline.weight(.semibold))
Spacer()
Menu {
ForEach(healthMetricsList) { candidate in
Button {
statsHealthMetricRaw = candidate.rawValue
} label: {
if candidate == metric {
Label(candidate.name, systemImage: "checkmark")
} else {
Text(candidate.name)
}
}
}
} label: {
HStack(spacing: 4) {
Image(safeSymbol: metric.symbolName)
.font(.system(size: 11, weight: .semibold))
Text(metric.name)
.font(.caption.weight(.semibold))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(.pink)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.pink.opacity(0.12), in: Capsule())
}
.accessibilityLabel(Text("추이를 볼 지표 선택"))
}
Chart(points) { point in
LineMark(
x: .value("날짜", point.dayKey, unit: .day),
y: .value(metric.name, point.value)
)
.foregroundStyle(.pink)
.symbol(.circle)
.interpolationMethod(.monotone)
}
.chartXAxis {
if span == .week {
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 { healthUnitLabel(metric) }
.frame(height: 200)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
/// · ( )
private func healthSummaryCard(days: [Date]) -> some View {
let rows: [(metric: HealthMetric, total: Double)] = healthMetricsList.compactMap { metric in
let total = days.reduce(0.0) { $0 + (HealthDisplayValues.value(metric, dayKey: $1) ?? 0) }
return total > 0 ? (metric, total) : nil
}
return chartCard("건강 지표 합계·평균") {
if rows.isEmpty {
emptyHealthText
} else {
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) {
GridRow {
Text("지표")
.fontWeight(.semibold)
Text("합계")
.gridColumnAlignment(.trailing)
Text("하루 평균")
.gridColumnAlignment(.trailing)
if span == .month && !rolling {
Text("주 평균")
.gridColumnAlignment(.trailing)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
ForEach(rows, id: \.metric) { row in
GridRow {
HStack(spacing: 8) {
Image(safeSymbol: row.metric.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(Color.pink, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
Text(row.metric.name)
.font(.footnote)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.metric.valueLabel(row.total))
.font(.footnote.weight(.semibold).monospacedDigit())
.foregroundStyle(.pink)
Text(row.metric.valueLabel(row.total / Double(elapsedDayCount)))
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
if span == .month && !rolling {
Text(row.metric.valueLabel(row.total / Double(elapsedWeekCount)))
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
private var emptyHealthText: some View {
Text("이 기간의 건강 데이터가 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
// 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 }
}