From 5475590a472a3f8be7ea06864ffc786901bad455 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 9 Jul 2026 04:36:14 +0900 Subject: [PATCH] feat: extract stats tab and enhance progress tracking UI [Main Tab & Routing] - feat(main): add options for goal widget to show aggregate or individual (up to 3) resolutions - fix(main): route long-press 'view records' directly to the filtered Records tab [Statistics & Charts] - feat(stats): separate Statistics into an independent main tab and remove from Records - feat(stats): add multi-select filters for tags and actions (default: all) - feat(stats): add daily horizontal bar charts for tag ratio comparison - feat(stats): add weekly line charts by day and cumulative bar charts - feat(stats): add monthly line charts (by day and week) and cumulative bar charts [Core Logic] - feat(goals): update logic for 'maintain below' targets to show 100% if met, 0% if exceeded --- .../UserInterfaceState.xcuserstate | Bin 21209 -> 22039 bytes myApp/Haru_Danim/IOS/ContentView.swift | 60 +- myApp/Haru_Danim/IOS/Core/DebugSeed.swift | 22 +- myApp/Haru_Danim/IOS/Core/QuestProgress.swift | 22 +- myApp/Haru_Danim/IOS/Core/Settings.swift | 19 + myApp/Haru_Danim/IOS/Localizable.xcstrings | 37 +- myApp/Haru_Danim/IOS/Views/GoalViews.swift | 2 +- myApp/Haru_Danim/IOS/Views/HistoryView.swift | 381 +-------- myApp/Haru_Danim/IOS/Views/MainView.swift | 115 ++- myApp/Haru_Danim/IOS/Views/SettingsView.swift | 10 +- myApp/Haru_Danim/IOS/Views/StatsTabView.swift | 737 ++++++++++++++++++ 11 files changed, 987 insertions(+), 418 deletions(-) create mode 100644 myApp/Haru_Danim/IOS/Views/StatsTabView.swift diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 9bfee6461234d12bc0417acd0b5b16f16c5c68e1..9af87546f76bf598db818084b26002078aa12e0a 100644 GIT binary patch delta 11911 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 KZ^O0e=>GxfEIb1M diff --git a/myApp/Haru_Danim/IOS/ContentView.swift b/myApp/Haru_Danim/IOS/ContentView.swift index 6c33c7a..d2e0b19 100644 --- a/myApp/Haru_Danim/IOS/ContentView.swift +++ b/myApp/Haru_Danim/IOS/ContentView.swift @@ -73,10 +73,37 @@ struct SplashView: View { } } +// MARK: - 탭 간 이동 라우터 + +/// 탭 전환과 탭 간 전달값 관리 (예: 모음 탭 '기록 확인' → 기록 탭 행동 필터) +@Observable +final class AppRouter { + var tabSelection: String = { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "startTab") { + return raw + } + #endif + return AppTab.main.rawValue + }() + + /// 기록 탭이 열릴 때 적용할 행동 필터 (기록 탭에서 소비 후 nil로 되돌림) + var pendingHistoryActionID: PersistentIdentifier? + + /// 기록 탭으로 이동하며 행동 필터를 예약. 기록 탭이 노출 탭에 없으면 더보기로 이동. + func openHistory(filtering actionID: PersistentIdentifier, visibleTabsRaw: String) { + pendingHistoryActionID = actionID + let visible = AppTab.visibleTabs(from: visibleTabsRaw) + tabSelection = visible.contains(.history) ? AppTab.history.rawValue : AppTab.moreTabValue + } +} + // MARK: - 탭 정의 enum AppTab: String, CaseIterable, Identifiable { - case main, action, tag, goal, history, settings + case main, action, tag, goal, history, stats, settings + + static let moreTabValue = "more" var id: String { rawValue } @@ -87,6 +114,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: return "꼬리표" case .goal: return "목표" case .history: return "기록" + case .stats: return "통계" case .settings: return "설정" } } @@ -98,6 +126,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: return "tag.fill" case .goal: return "flag.checkered" case .history: return "calendar" + case .stats: return "chart.xyaxis.line" case .settings: return "gearshape.fill" } } @@ -117,6 +146,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .tag: TagListView() case .goal: GoalListView() case .history: HistoryView() + case .stats: StatsTabView() case .settings: SettingsView() } } @@ -127,16 +157,7 @@ enum AppTab: String, CaseIterable, Identifiable { struct MainTabView: View { @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs - private static let moreTabValue = "more" - - @State private var selection: String = { - #if DEBUG - if let raw = UserDefaults.standard.string(forKey: "startTab") { - return raw - } - #endif - return AppTab.main.rawValue - }() + @State private var router = AppRouter() private var visibleTabs: [AppTab] { AppTab.visibleTabs(from: visibleTabsRaw) @@ -147,7 +168,7 @@ struct MainTabView: View { } var body: some View { - TabView(selection: $selection) { + TabView(selection: $router.tabSelection) { ForEach(visibleTabs) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { @@ -155,23 +176,24 @@ struct MainTabView: View { } } } - Tab("더보기", systemImage: "ellipsis", value: Self.moreTabValue) { + Tab("더보기", systemImage: "ellipsis", value: AppTab.moreTabValue) { MoreTabView(tabs: moreTabs) } } + .environment(router) .onChange(of: visibleTabsRaw) { // 노출 탭에서 빠진 탭이 선택돼 있으면 더보기로 이동 - let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue] - if !valid.contains(selection) { - selection = Self.moreTabValue + let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] + if !valid.contains(router.tabSelection) { + router.tabSelection = AppTab.moreTabValue } } .onAppear { #if DEBUG // 검증용: -startTab이 노출 탭에 없으면 더보기 대신 그 탭 화면으로 안내되도록 보정 - let valid = visibleTabs.map(\.rawValue) + [Self.moreTabValue] - if !valid.contains(selection) { - selection = Self.moreTabValue + let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] + if !valid.contains(router.tabSelection) { + router.tabSelection = AppTab.moreTabValue } #endif } diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift index 962f064..127785f 100644 --- a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -46,7 +46,10 @@ enum DebugSeed { let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4) water.tags = [life] water.promptsForNote = true - for action in [reading, english, running, pushup, water] { + // '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함) + let video = Action(name: "영상 시청", symbolName: "play.rectangle.fill", trackingType: .time, sortOrder: 5) + video.tags = [life] + for action in [reading, english, running, pushup, water, video] { context.insert(action) } @@ -66,6 +69,12 @@ enum DebugSeed { if daysAgo % 3 != 0 { context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15))) } + // 영상 시청: 짝수 날은 40분(1시간 이하 유지), 홀수 날은 1시간 30분(초과) + if daysAgo % 2 == 0 { + context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 13, minute: 10))) + } else { + context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 14, minute: 0))) + } } // 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용 let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40)) @@ -96,7 +105,6 @@ enum DebugSeed { startDate: cal.date(byAdding: .day, value: -10, to: now)!, endDate: cal.date(byAdding: .day, value: 30, to: now)! ) - toeic.showsOnMain = true context.insert(toeic) let englishQuest = Quest(goal: toeic) englishQuest.targetAction = english @@ -113,6 +121,8 @@ enum DebugSeed { startDate: cal.date(byAdding: .day, value: -20, to: now)!, endDate: nil ) + // 다짐 3개(이하 유지 포함)라 모음 탭 목표 카드 검증에 적합 + health.showsOnMain = true context.insert(health) let waterQuest = Quest(goal: health) waterQuest.targetAction = water @@ -130,6 +140,14 @@ enum DebugSeed { runQuest.targetSeconds = 30 * 60 runQuest.direction = .atLeast context.insert(runQuest) + // '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용) + let videoQuest = Quest(goal: health) + videoQuest.targetAction = video + videoQuest.measure = .time + videoQuest.period = .daily + videoQuest.targetSeconds = 3600 + videoQuest.direction = .atMost + context.insert(videoQuest) } } #endif diff --git a/myApp/Haru_Danim/IOS/Core/QuestProgress.swift b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift index 239dde9..025b205 100644 --- a/myApp/Haru_Danim/IOS/Core/QuestProgress.swift +++ b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift @@ -27,10 +27,26 @@ struct QuestProgressResult { let target: Double let direction: QuestDirection - /// 게이지 표시용 비율 (0...1) + /// 게이지 표시용 비율 (0...1). + /// '이하 유지'는 기준을 넘지 않는 동안 100%(달성 유지), 초과하는 순간 0%(실패)로 취급. var ratio: Double { - guard target > 0 else { return 0 } - return min(value / target, 1) + switch direction { + case .atLeast: + guard target > 0 else { return 0 } + return min(value / target, 1) + case .atMost: + return value <= target ? 1 : 0 + } + } + + /// 퍼센트 문구 표시용 값. '이상 달성'은 100%를 넘을 수 있고, '이하 유지'는 100% 또는 0%. + var displayRatio: Double { + switch direction { + case .atLeast: + return target > 0 ? value / target : 0 + case .atMost: + return value <= target ? 1 : 0 + } } /// 현재 시점 기준 달성 여부 diff --git a/myApp/Haru_Danim/IOS/Core/Settings.swift b/myApp/Haru_Danim/IOS/Core/Settings.swift index 5eb1ad2..6afd6ff 100644 --- a/myApp/Haru_Danim/IOS/Core/Settings.swift +++ b/myApp/Haru_Danim/IOS/Core/Settings.swift @@ -26,6 +26,25 @@ enum SettingsKeys { static let minSessionSeconds = "settings.minSessionSeconds" /// 탭바에 직접 노출할 탭(더보기 제외 1~3개). AppTab rawValue를 쉼표로 연결한 문자열 static let visibleTabs = "settings.visibleTabs" + /// 모음 탭 목표 카드 표시 방식. GoalCardStyle rawValue ("perQuest" | "combined") + static let goalCardStyle = "settings.goalCardStyle" +} + +/// 모음 탭 목표 진행 현황 카드의 표시 방식 +enum GoalCardStyle: String, CaseIterable, Identifiable { + /// 다짐(최대 3개)별로 하루/주간/월간 진행률을 각각 표시 + case perQuest + /// 소속 다짐 전체의 하루/주간/월간 진행률을 합산(평균)해 한 번에 표시 + case combined + + var id: String { rawValue } + + var label: String { + switch self { + case .perQuest: return "다짐별로 각각" + case .combined: return "전체 다짐 합산" + } + } } enum TabBarConfig { diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings index d1ad832..af0e114 100644 --- a/myApp/Haru_Danim/IOS/Localizable.xcstrings +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -179,9 +179,6 @@ }, "누적 기록" : { - }, - "누적 횟수" : { - }, "다이나믹 아일랜드 · 잠금화면" : { @@ -269,7 +266,7 @@ "모음 탭" : { }, - "목록·타임테이블·통계 모두에 필터가 적용돼요." : { + "목록과 타임테이블 모두에 필터가 적용돼요." : { }, "목표" : { @@ -374,11 +371,14 @@ }, "선택" : { + }, + "선택한 꼬리표·행동의 기록만 통계에 반영돼요." : { + }, "선택한 꼬리표가 붙은 행동의 기록만 표시돼요." : { }, - "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요." : { + "선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요." : { }, "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : { @@ -527,6 +527,9 @@ }, "주기마다 목표량을 넘지 않는 것이 목표예요." : { + }, + "주차" : { + }, "즐겨찾기" : { @@ -581,14 +584,17 @@ }, "테마" : { + }, + "통계" : { + + }, + "통계 필터" : { + }, "특정 시각 지정" : { }, - "평균" : { - - }, - "평균 %@" : { + "표시 방식" : { }, "표시 안 함" : { @@ -637,9 +643,22 @@ }, "한 줄에 표시할 개수" : { + }, + "해제한 꼬리표에만 속한 행동은 통계에서 제외돼요." : { + }, "행동" : { + }, + "행동 %lld/%lld개 표시 중" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "행동 %1$lld/%2$lld개 표시 중" + } + } + } }, "행동 %lld개" : { diff --git a/myApp/Haru_Danim/IOS/Views/GoalViews.swift b/myApp/Haru_Danim/IOS/Views/GoalViews.swift index 62bbf76..21a3827 100644 --- a/myApp/Haru_Danim/IOS/Views/GoalViews.swift +++ b/myApp/Haru_Danim/IOS/Views/GoalViews.swift @@ -219,7 +219,7 @@ struct QuestRow: View { .foregroundStyle(.secondary) ProgressView(value: result.ratio) .tint(over ? .red : quest.targetColor) - Text(Format.percent(result.target > 0 ? result.value / result.target : 0)) + Text(Format.percent(result.displayRatio)) .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(over ? .red : .primary) } diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift index d3e142b..8afc7a5 100644 --- a/myApp/Haru_Danim/IOS/Views/HistoryView.swift +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -2,15 +2,14 @@ // HistoryView.swift // Haru_Danim // -// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) / 통계 (CLAUDE.md §6.5) +// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) (CLAUDE.md §6.5 — 통계는 통계 탭으로 분리) // import SwiftUI import SwiftData -import Charts enum HistoryMode: String, CaseIterable, Identifiable { - case list, timetable, stats + case list, timetable var id: String { rawValue } @@ -18,7 +17,6 @@ enum HistoryMode: String, CaseIterable, Identifiable { switch self { case .list: return "목록" case .timetable: return "타임테이블" - case .stats: return "통계" } } } @@ -47,6 +45,8 @@ struct CountItem: Identifiable { // MARK: - 기록 탭 본체 struct HistoryView: View { + @Environment(AppRouter.self) private var router + @Query private var sessions: [TimeSession] @Query private var entries: [CountEntry] @Query(sort: \Tag.sortOrder) private var tags: [Tag] @@ -63,15 +63,6 @@ struct HistoryView: View { return .list }() @State private var timetableWeekly = false - @State private var statSpan: StatSpan = { - #if DEBUG - if let raw = UserDefaults.standard.string(forKey: "statSpan"), - let span = StatSpan(rawValue: raw) { - return span - } - #endif - return .day - }() @State private var editingSession: TimeSession? @State private var editingEntry: CountEntry? @State private var showingCalendar = false @@ -111,14 +102,6 @@ struct HistoryView: View { onTapSession: { editingSession = $0 }, onTapEntry: { editingEntry = $0 } ) - case .stats: - StatsView( - statSpan: $statSpan, - selectedDayKey: selectedDayKey, - segments: segments(in:), - counts: counts(in:), - math: math - ) } } .background(AppTheme.background) @@ -149,6 +132,20 @@ struct HistoryView: View { filterActionIDs: $filterActionIDs ) } + .onAppear(perform: consumePendingFilter) + .onChange(of: router.pendingHistoryActionID) { + consumePendingFilter() + } + } + + /// 모음 탭 '기록 확인'에서 예약한 행동 필터를 적용 (오늘 날짜의 목록 뷰로 전환) + private func consumePendingFilter() { + guard let id = router.pendingHistoryActionID else { return } + filterTag = nil + filterActionIDs = [id] + mode = .list + selectedDayKey = math.dayKey(for: .now) + router.pendingHistoryActionID = nil } // MARK: 필터 (꼬리표 1개 또는 행동 여러 개) @@ -566,346 +563,6 @@ struct TimetableView: View { } } -// MARK: - 통계 뷰 (Swift Charts) - -struct TagStat: Identifiable { - let name: String - let color: Color - let seconds: TimeInterval - let count: Int - - var id: String { name } -} - -struct DailyTagStat: Identifiable { - let dayKey: Date - let tagName: String - let color: Color - let hours: Double - - var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(tagName)" } -} - -struct StatsView: View { - @Binding var statSpan: StatSpan - let selectedDayKey: Date - let segments: (Range) -> [SessionSegmentItem] - let counts: (Range) -> [CountItem] - let math: DayMath - - private var spanRange: Range { - let anchor = math.dayRange(forKey: selectedDayKey).lowerBound - switch statSpan { - 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 { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Picker("기간", selection: $statSpan) { - ForEach(StatSpan.allCases) { span in - Text(span.label).tag(span) - } - } - .pickerStyle(.segmented) - - let stats = tagStats(in: spanRange) - timeChartCard(stats) - countChartCard(stats) - if statSpan != .day { - let points = trendPoints() - timeTrendCard(points) - countTrendCard(points) - } - weeklyTrendCard - } - .padding() - } - } - - // MARK: 태그별 집계 - - private func tagStats(in range: Range) -> [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 [("꼬리표 없음", Color.gray)] } - return action.sortedTags.map { ($0.name, $0.color) } - } - - for item in segments(range) { - for (name, color) in tagNames(for: item.action) { - seconds[name, default: 0] += item.duration - colors[name] = color - } - } - for item in counts(range) { - for (name, color) in tagNames(for: item.action) { - countsByTag[name, default: 0] += item.entry.amount - 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 } - } - - // MARK: 차트 카드 - - @ViewBuilder - private func timeChartCard(_ 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 countChartCard(_ 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: 추세선 (주간/월간, 꺾은선 그래프 + 평균) - - private struct TrendPoint: Identifiable { - let dayKey: Date - let hours: Double - let count: Int - let cumulativeCount: Int - - var id: Date { dayKey } - } - - /// 선택 기간의 하루별 합산 시간과 누적 횟수 - private func trendPoints() -> [TrendPoint] { - var cumulative = 0 - return math.dayKeys(in: spanRange).map { key in - let range = math.dayRange(forKey: key) - let seconds = segments(range).reduce(0.0) { $0 + $1.duration } - let count = counts(range).reduce(0) { $0 + $1.entry.amount } - cumulative += count - return TrendPoint(dayKey: key, hours: seconds / 3600, count: count, cumulativeCount: cumulative) - } - } - - /// 평균 계산에 쓸 "이미 지나간(시작된)" 날 수 — 이번 주/이번 달의 미래 날짜로 평균이 희석되지 않게 - private func elapsedDayCount(_ points: [TrendPoint]) -> Int { - max(points.filter { math.dayRange(forKey: $0.dayKey).lowerBound <= .now }.count, 1) - } - - @ViewBuilder - private func timeTrendCard(_ points: [TrendPoint]) -> some View { - let totalHours = points.reduce(0.0) { $0 + $1.hours } - let avgHours = totalHours / Double(elapsedDayCount(points)) - chartCard("시간 추세 (하루 합산)") { - if totalHours == 0 { - emptyChartText - } else { - Chart { - ForEach(points) { point in - LineMark( - x: .value("날짜", point.dayKey, unit: .day), - y: .value("시간", point.hours) - ) - .foregroundStyle(AppTheme.green) - .interpolationMethod(.monotone) - .symbol(.circle) - } - RuleMark(y: .value("평균", avgHours)) - .foregroundStyle(AppTheme.yellow) - .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 4])) - .annotation(position: .top, alignment: .trailing) { - Text("평균 \(Format.durationShort(avgHours * 3600))") - .font(.caption2.weight(.semibold)) - .foregroundStyle(AppTheme.yellow) - } - } - .chartYAxisLabel("시간(h)") - .frame(height: 180) - averageRow( - total: Format.durationShort(totalHours * 3600), - average: Format.durationShort(avgHours * 3600) - ) - } - } - } - - @ViewBuilder - private func countTrendCard(_ points: [TrendPoint]) -> some View { - let totalCount = points.last?.cumulativeCount ?? 0 - let avgCount = Double(totalCount) / Double(elapsedDayCount(points)) - chartCard("횟수 추세 (누적)") { - if totalCount == 0 { - emptyChartText - } else { - Chart { - ForEach(points) { point in - LineMark( - x: .value("날짜", point.dayKey, unit: .day), - y: .value("누적 횟수", point.cumulativeCount) - ) - .foregroundStyle(AppTheme.green) - .interpolationMethod(.monotone) - .symbol(.circle) - } - } - .chartYAxisLabel("누적 횟수") - .frame(height: 180) - averageRow( - total: "\(totalCount)회", - average: String(format: "%.1f회", avgCount) - ) - } - } - } - - /// 그래프 아래 요약: 기간 합계 · 하루 평균 - private func averageRow(total: String, average: String) -> some View { - HStack(spacing: 0) { - summaryCell(label: "\(statSpan.label) 합계", value: total) - Divider().frame(height: 28) - summaryCell(label: "하루 평균", value: 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) - } - .frame(maxWidth: .infinity) - } - - /// 최근 7일 꼬리표별 시간 추이 (스택 막대) - private var weeklyTrendCard: some View { - let data = recentDailyStats() - let names = orderedNames(from: data) - let colors = orderedColors(from: data, names: names) - return chartCard("최근 7일 시간 추이") { - if data.isEmpty { - emptyChartText - } else { - Chart(data) { item in - BarMark( - x: .value("날짜", item.dayKey, unit: .day), - y: .value("시간", item.hours) - ) - .foregroundStyle(by: .value("꼬리표", item.tagName)) - .cornerRadius(3) - } - .chartForegroundStyleScale(domain: names, range: colors) - .chartYAxisLabel("시간(h)") - .frame(height: 220) - } - } - } - - private func recentDailyStats() -> [DailyTagStat] { - var result: [DailyTagStat] = [] - for offset in (0..<7).reversed() { - guard let key = math.calendar.date(byAdding: .day, value: -offset, to: selectedDayKey) else { continue } - let range = math.dayRange(forKey: key) - for stat in tagStats(in: range) where stat.seconds > 0 { - result.append(DailyTagStat( - dayKey: key, - tagName: stat.name, - color: stat.color, - hours: stat.seconds / 3600 - )) - } - } - return result - } - - private func orderedNames(from data: [DailyTagStat]) -> [String] { - var seen = Set() - return data.map(\.tagName).filter { seen.insert($0).inserted } - } - - private func orderedColors(from data: [DailyTagStat], names: [String]) -> [Color] { - names.map { name in - data.first { $0.tagName == name }?.color ?? .gray - } - } - - private func chartCard(_ title: String, @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: - 기록 필터 시트 (꼬리표 1개 또는 행동 다중 선택) struct HistoryFilterSheet: View { @@ -965,7 +622,7 @@ struct HistoryFilterSheet: View { } header: { Text("행동으로 보기 (여러 개 선택 가능)") } footer: { - Text("목록·타임테이블·통계 모두에 필터가 적용돼요.") + Text("목록과 타임테이블 모두에 필터가 적용돼요.") } } .scrollContentBackground(.hidden) diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index 6a946e4..2383cc3 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -16,8 +16,11 @@ struct MainView: View { private var runningSessions: [TimeSession] @Query(filter: #Predicate { $0.showsOnMain }) private var pinnedGoals: [Goal] + @Environment(AppRouter.self) private var router + @AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3 @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 + @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs @State private var isEditing: Bool = { #if DEBUG @@ -29,7 +32,6 @@ struct MainView: View { @State private var draggingAction: Action? @State private var settingsEditingAction: Action? @State private var recordsAction: Action? - @State private var recordsStartWithAdd = false @State private var deletingAction: Action? @State private var memoSession: TimeSession? @State private var memoEntry: CountEntry? @@ -72,7 +74,7 @@ struct MainView: View { ActionEditorView(action: action) } .sheet(item: $recordsAction) { action in - ActionRecordsSheet(action: action, startWithAdd: recordsStartWithAdd) + ActionRecordsSheet(action: action, startWithAdd: true) } .sheet(item: $memoSession) { session in SessionMemoSheet(session: session) @@ -179,13 +181,15 @@ struct MainView: View { base .contextMenu { Button { - recordsStartWithAdd = false - recordsAction = action + // 기록 탭으로 이동해 이 행동만 필터링된 상태로 표시 + router.openHistory( + filtering: action.persistentModelID, + visibleTabsRaw: visibleTabsRaw + ) } label: { Label("기록 확인", systemImage: "list.bullet.rectangle") } Button { - recordsStartWithAdd = true recordsAction = action } label: { Label( @@ -279,6 +283,12 @@ struct MainView: View { struct GoalSummaryCard: View { let goal: Goal + @AppStorage(SettingsKeys.goalCardStyle) private var cardStyleRaw = GoalCardStyle.perQuest.rawValue + + private var cardStyle: GoalCardStyle { + GoalCardStyle(rawValue: cardStyleRaw) ?? .perQuest + } + var body: some View { NavigationLink { GoalDetailView(goal: goal) @@ -313,8 +323,15 @@ struct GoalSummaryCard: View { .foregroundStyle(.secondary) } } - ForEach(goal.sortedQuests.prefix(3)) { quest in - questLine(quest) + if !goal.sortedQuests.isEmpty { + switch cardStyle { + case .perQuest: + ForEach(goal.sortedQuests.prefix(3)) { quest in + questSpanBlock(quest) + } + case .combined: + combinedBlock + } } } .padding(14) @@ -325,26 +342,82 @@ struct GoalSummaryCard: View { .buttonStyle(.plain) } - /// 다짐 한 줄 요약: 현재 주기 진행률 게이지 + 퍼센트 - @ViewBuilder - private func questLine(_ quest: Quest) -> some View { - if let result = QuestProgress(quest: quest).current() { - let over = quest.direction == .atMost && result.value > result.target - HStack(spacing: 8) { + /// 표시 방식 A: 다짐 하나의 하루/주간/월간 진행률을 각각 표시 + private func questSpanBlock(_ quest: Quest) -> some View { + let progress = QuestProgress(quest: quest) + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: quest.targetSymbol) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(quest.targetColor, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) Text(quest.targetName) - .font(.caption) + .font(.caption.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) - .frame(width: 88, alignment: .leading) - ProgressView(value: result.ratio) - .tint(over ? .red : quest.targetColor) - Text(Format.percent(result.target > 0 ? result.value / result.target : 0)) - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(over ? .red : .secondary) - .frame(width: 40, alignment: .trailing) + } + HStack(spacing: 10) { + ForEach(StatSpan.allCases) { span in + let result = progress.spanProgress(span) + let over = quest.direction == .atMost && result.value > result.target + miniGauge( + label: span.label, + ratio: result.ratio, + percentText: Format.percent(result.displayRatio), + color: over ? .red : quest.targetColor, + emphasized: over + ) + } } } } + + /// 표시 방식 B: 소속 다짐 전체의 하루/주간/월간 진행률 평균을 한 줄로 표시 + private var combinedBlock: some View { + HStack(spacing: 10) { + ForEach(StatSpan.allCases) { span in + let ratio = combinedRatio(span) + miniGauge( + label: "\(span.label) 전체", + ratio: ratio, + percentText: Format.percent(ratio), + color: goal.color, + emphasized: false + ) + } + } + } + + /// 소속 다짐들의 span 진행률 평균 (이하 유지 다짐은 100% 또는 0%로 반영됨) + private func combinedRatio(_ span: StatSpan) -> Double { + let quests = goal.sortedQuests + guard !quests.isEmpty else { return 0 } + let sum = quests.reduce(0.0) { + $0 + QuestProgress(quest: $1).spanProgress(span).ratio + } + return sum / Double(quests.count) + } + + private func miniGauge( + label: String, + ratio: Double, + percentText: String, + color: Color, + emphasized: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + ProgressView(value: min(max(ratio, 0), 1)) + .tint(color) + Text(percentText) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(emphasized ? .red : .secondary) + } + .frame(maxWidth: .infinity) + } } // MARK: - 측정 종료 메모 시트 diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift index 76f147b..625c762 100644 --- a/myApp/Haru_Danim/IOS/Views/SettingsView.swift +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(SettingsKeys.isPremium) private var isPremium = false @AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0 @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs + @AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue private var visibleTabs: [AppTab] { AppTab.visibleTabs(from: visibleTabsRaw) @@ -75,10 +76,17 @@ struct SettingsView: View { Text(goal.title).tag(Optional(goal.persistentModelID)) } } + if goals.contains(where: \.showsOnMain) { + Picker("표시 방식", selection: $goalCardStyle) { + ForEach(GoalCardStyle.allCases) { style in + Text(style.label).tag(style.rawValue) + } + } + } } header: { Text("모음 탭") } footer: { - Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요.") + Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. ‘다짐별로 각각’은 다짐(최대 3개)마다 하루·주간·월간 진행률을, ‘전체 다짐 합산’은 소속 다짐 전체의 평균 진행률을 한 줄로 보여줘요.") } Section { Picker("주 시작 요일", selection: $weekStartWeekday) { diff --git a/myApp/Haru_Danim/IOS/Views/StatsTabView.swift b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift new file mode 100644 index 0000000..718d0ab --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/StatsTabView.swift @@ -0,0 +1,737 @@ +// +// StatsTabView.swift +// Haru_Danim +// +// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터 +// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교) +// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 +// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 +// + +import SwiftUI +import SwiftData +import Charts + +struct StatsTabView: View { + @Query private var sessions: [TimeSession] + @Query private var entries: [CountEntry] + @Query(sort: \Tag.sortOrder) private var tags: [Tag] + @Query(sort: \Action.sortOrder) private var allActions: [Action] + + @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 excludedTagIDs: Set = [] + @State private var excludedActionIDs: Set = [] + + private var math: DayMath { DayMath() } + + private var spanRange: Range { + 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 + } + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + switch span { + case .day: + dayCharts + case .week: + weekCharts + case .month: + monthCharts + } + } + .padding() + } + } + .background(AppTheme.background) + .navigationTitle("통계") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingFilter = true + } label: { + Image(systemName: isFiltering + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + } + } + .sheet(isPresented: $showingFilter) { + StatsFilterSheet( + tags: tags, + actions: allActions, + excludedTagIDs: $excludedTagIDs, + excludedActionIDs: $excludedActionIDs + ) + } + } + + // MARK: 기간 이동 헤더 + + private var periodHeader: some View { + HStack { + Button { + movePeriod(-1) + } label: { + Image(systemName: "chevron.left") + .frame(width: 44, height: 36) + .contentShape(Rectangle()) + } + 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()) + } + 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 "오늘" + case .week: return "이번 주" + case .month: return "이번 달" + } + } + + 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 { + !excludedTagIDs.isEmpty || !excludedActionIDs.isEmpty + } + + /// 행동이 통계에 포함되는지: 직접 해제되지 않았고, 선택된 꼬리표가 하나라도 있어야 함 + private func passesFilter(_ action: Action) -> Bool { + guard !excludedActionIDs.contains(action.persistentModelID) else { return false } + if action.tags.isEmpty { return true } + return action.tags.contains { !excludedTagIDs.contains($0.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 { + excludedTagIDs = [] + excludedActionIDs = [] + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + .padding(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .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: 데이터 수집 (구간 겹침으로 하루 경계 분할 자동 반영) + + private func segments(in range: Range) -> [SessionSegmentItem] { + let now = Date.now + var items: [SessionSegmentItem] = [] + for session in sessions { + guard let action = session.action, passesFilter(action) else { continue } + let end = session.endAt ?? now + let lower = max(session.startAt, range.lowerBound) + let upper = min(end, range.upperBound) + guard lower < upper else { continue } + items.append(SessionSegmentItem(session: session, action: action, range: lower..) -> [CountItem] { + entries + .filter { range.contains($0.timestamp) } + .compactMap { entry in + entry.action.map { CountItem(entry: entry, action: $0) } + } + .filter { passesFilter($0.action) } + } + + /// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게) + private var elapsedDayCount: Int { + max( + math.dayKeys(in: spanRange) + .filter { math.dayRange(forKey: $0).lowerBound <= .now } + .count, + 1 + ) + } + + // MARK: 하루 — 꼬리표별 가로 막대 (비율 비교) + + @ViewBuilder + private var dayCharts: some View { + let stats = tagStats(in: spanRange) + tagTimeBarCard(stats) + tagCountBarCard(stats) + } + + private func tagStats(in range: Range) -> [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 [("꼬리표 없음", Color.gray)] } + return action.sortedTags.map { ($0.name, $0.color) } + } + + for item in segments(in: range) { + for (name, color) in tagNames(for: item.action) { + seconds[name, default: 0] += item.duration + colors[name] = color + } + } + for item in counts(in: range) { + for (name, color) in tagNames(for: item.action) { + countsByTag[name, default: 0] += item.entry.amount + 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 var weekCharts: some View { + dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true) + dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true) + totalsBarCard("주간 시간 합계", type: .time) + totalsBarCard("주간 횟수 합계", type: .count) + } + + // MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + + @ViewBuilder + private var monthCharts: some View { + weeklyLineCard("행동별 시간 (주차별)", type: .time) + weeklyLineCard("행동별 횟수 (주차별)", type: .count) + dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false) + dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false) + totalsBarCard("월간 시간 합계", type: .time) + totalsBarCard("월간 횟수 합계", type: .count) + } + + // 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) -> [Action] { + let agg = Aggregator(math: math) + return filteredActions + .filter { $0.trackingType == type } + .filter { action in + switch type { + case .time: return agg.seconds(for: action, in: spanRange) > 0 + case .count: return agg.count(for: action, in: spanRange) > 0 + } + } + } + + private func dailyPoints(for actions: [Action], type: TrackingType) -> [ActionDayPoint] { + let agg = Aggregator(math: math) + var result: [ActionDayPoint] = [] + for key in math.dayKeys(in: spanRange) { + let range = math.dayRange(forKey: key) + for action in actions { + let value: Double = switch type { + case .time: agg.seconds(for: action, in: range) / 3600 + case .count: Double(agg.count(for: action, in: range)) + } + result.append(ActionDayPoint(dayKey: key, actionName: action.name, value: value)) + } + } + return result + } + + /// 한 달을 주 단위(주 시작 요일 설정 기준, 월 경계에서 잘림)로 쪼갠 범위들 + private var weekRangesInMonth: [Range] { + let range = spanRange + var result: [Range] = [] + var cursor = range.lowerBound + while cursor < range.upperBound { + let week = math.weekRange(containing: cursor) + result.append(max(week.lowerBound, range.lowerBound).. [ActionWeekPoint] { + let agg = Aggregator(math: math) + var result: [ActionWeekPoint] = [] + for (index, weekRange) in weekRangesInMonth.enumerated() { + for action in actions { + let value: Double = switch type { + case .time: agg.seconds(for: action, in: weekRange) / 3600 + case .count: Double(agg.count(for: action, in: weekRange)) + } + result.append(ActionWeekPoint(weekLabel: "\(index + 1)주차", actionName: action.name, value: value)) + } + } + return result + } + + private func totals(for type: TrackingType) -> [ActionTotal] { + let agg = Aggregator(math: math) + return activeActions(for: type) + .map { action in + let value: Double = switch type { + case .time: agg.seconds(for: action, in: spanRange) + case .count: Double(agg.count(for: action, in: spanRange)) + } + return ActionTotal(name: action.name, color: action.color, value: value) + } + .sorted { $0.value > $1.value } + } + + private func valueLabel(_ value: Double, type: TrackingType) -> String { + switch type { + case .time: return Format.durationShort(value) + case .count: return "\(Int(value.rounded()))회" + } + } + + // MARK: 꺾은선 카드 (선 색 = 행동/태그 색, 범례 표시) + + @ViewBuilder + private func dailyLineCard(_ title: String, type: TrackingType, weekdayAxis: Bool) -> some View { + let actions = activeActions(for: type) + let names = actions.map(\.name) + let colors = actions.map(\.color) + chartCard(title) { + if actions.isEmpty { + emptyChartText + } else { + Chart(dailyPoints(for: actions, type: type)) { point in + LineMark( + x: .value("날짜", point.dayKey, unit: .day), + y: .value(type == .time ? "시간" : "횟수", 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 ? "시간(h)" : "횟수") + .frame(height: 200) + } + } + } + + @ViewBuilder + private func weeklyLineCard(_ title: String, type: TrackingType) -> some View { + let actions = activeActions(for: type) + let names = actions.map(\.name) + let colors = actions.map(\.color) + chartCard(title) { + if actions.isEmpty { + emptyChartText + } else { + Chart(weeklyPoints(for: actions, type: type)) { point in + LineMark( + x: .value("주차", point.weekLabel), + y: .value(type == .time ? "시간" : "횟수", point.value) + ) + .foregroundStyle(by: .value("행동", point.actionName)) + .symbol(by: .value("행동", point.actionName)) + .interpolationMethod(.monotone) + } + .chartForegroundStyleScale(domain: names, range: colors) + .chartYAxisLabel(type == .time ? "시간(h)" : "횟수") + .frame(height: 200) + } + } + } + + // MARK: 합계 막대 카드 (기간 누적 비율 비교 + 합계·하루 평균) + + @ViewBuilder + private func totalsBarCard(_ title: String, type: TrackingType) -> some View { + let items = totals(for: type) + chartCard(title) { + if items.isEmpty { + emptyChartText + } else { + Chart(items) { item in + BarMark( + x: .value(type == .time ? "시간" : "횟수", 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 ? "시간(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) + } + .frame(maxWidth: .infinity) + } + + // MARK: 공통 + + private func chartCard(_ title: String, @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 } +} + +// MARK: - 통계 필터 시트 (꼬리표·행동 다중 선택, 기본 전체 선택) + +struct StatsFilterSheet: View { + @Environment(\.dismiss) private var dismiss + let tags: [Tag] + let actions: [Action] + @Binding var excludedTagIDs: Set + @Binding var excludedActionIDs: Set + + var body: some View { + NavigationStack { + List { + Section { + selectableRow( + title: "전체 선택", + symbol: "checkmark.circle", + color: AppTheme.green, + selected: excludedTagIDs.isEmpty && excludedActionIDs.isEmpty + ) { + excludedTagIDs = [] + excludedActionIDs = [] + } + } + Section { + ForEach(tags) { tag in + selectableRow( + title: tag.name, + symbol: "tag.fill", + color: tag.color, + selected: !excludedTagIDs.contains(tag.persistentModelID) + ) { + toggle(tag.persistentModelID, in: &excludedTagIDs) + } + } + } header: { + Text("꼬리표") + } footer: { + Text("해제한 꼬리표에만 속한 행동은 통계에서 제외돼요.") + } + Section { + ForEach(actions) { action in + selectableRow( + title: action.name, + symbol: action.symbolName, + color: action.color, + selected: !excludedActionIDs.contains(action.persistentModelID) + ) { + toggle(action.persistentModelID, in: &excludedActionIDs) + } + } + } header: { + Text("행동") + } footer: { + Text("선택한 꼬리표·행동의 기록만 통계에 반영돼요.") + } + } + .scrollContentBackground(.hidden) + .background(AppTheme.background) + .navigationTitle("통계 필터") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("완료") { dismiss() } + } + } + } + } + + private func toggle(_ id: PersistentIdentifier, in set: inout Set) { + if set.contains(id) { + set.remove(id) + } else { + set.insert(id) + } + } + + private func selectableRow( + title: String, + symbol: String, + color: Color, + selected: Bool, + onTap: @escaping () -> Void + ) -> some View { + Button(action: onTap) { + HStack(spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(title) + .foregroundStyle(.primary) + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? AppTheme.green : Color.secondary.opacity(0.4)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +}