- Add Shared/LocalPrefs.swift: device-local UI preferences stored in App Group UserDefaults (never synced by iCloud), read identically by app, widgets, watch snapshot, and App Intents on the same device - Move to local storage: main-tab action button order (UUID array), pinned goal-progress cards on the main tab, goal quest-list collapse state, and per-action memo-prompt popup — reordering or repinning on iPhone no longer touches the iPad and vice versa - Defense logic: actions not in the local order list (e.g. newly received via CloudKit from another device) are automatically appended to the end, sorted by legacy sortOrder then creation date - One-time migration adopts existing @Model values (sortOrder, showsOnMain, isCollapsed, promptsForNote) into local prefs at launch; model properties stay in place so the CloudKit schema is untouched and records keep syncing - Data remains fully synced: actions, goals, quests, sessions, count entries, statistics, and goal/tag/quest list order are untouched by this change - Verified in simulator: legacy data migrates losslessly, grid renders from local order, unknown action lands at the end; iOS+watch+widgets build clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
794 lines
30 KiB
Swift
794 lines
30 KiB
Swift
//
|
|
// StatsTabView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터
|
|
// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교)
|
|
// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
|
// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct StatsTabView: 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]
|
|
@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 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
|
|
}
|
|
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
switch span {
|
|
case .day:
|
|
dayCharts
|
|
case .week:
|
|
weekCharts
|
|
case .month:
|
|
monthCharts
|
|
}
|
|
}
|
|
.padding()
|
|
Color.clear
|
|
.frame(height: 1)
|
|
.id("statsBottom")
|
|
}
|
|
.onAppear {
|
|
#if DEBUG
|
|
// 검증용: -statScrollBottom YES면 하단 요약 카드까지 자동 스크롤
|
|
if UserDefaults.standard.bool(forKey: "statScrollBottom") {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation { proxy.scrollTo("statsBottom", anchor: .bottom) }
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
.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) {
|
|
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
|
|
}
|
|
#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())
|
|
}
|
|
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 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)
|
|
}
|
|
.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<Date>) -> [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..<upper))
|
|
}
|
|
return items
|
|
}
|
|
|
|
private func counts(in range: Range<Date>) -> [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<Date>) -> [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 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)
|
|
summaryTableCard(
|
|
"행동별 합계·평균",
|
|
timeRows: actionSummaryRows(for: .time),
|
|
countRows: actionSummaryRows(for: .count)
|
|
)
|
|
summaryTableCard(
|
|
"꼬리표별 합계·평균",
|
|
timeRows: tagSummaryRows(for: .time),
|
|
countRows: tagSummaryRows(for: .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)
|
|
summaryTableCard(
|
|
"행동별 합계·평균",
|
|
timeRows: actionSummaryRows(for: .time),
|
|
countRows: actionSummaryRows(for: .count)
|
|
)
|
|
summaryTableCard(
|
|
"꼬리표별 합계·평균",
|
|
timeRows: tagSummaryRows(for: .time),
|
|
countRows: tagSummaryRows(for: .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<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) -> [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 String(localized: "\(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 ? 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: 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 ? 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: 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 ? 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) -> [SummaryRowItem] {
|
|
let agg = Aggregator(math: math)
|
|
return activeActions(for: type)
|
|
.map { action in
|
|
let total: Double = switch type {
|
|
case .time: agg.seconds(for: action, in: spanRange)
|
|
case .count: Double(agg.count(for: action, in: spanRange))
|
|
}
|
|
return SummaryRowItem(name: action.name, color: action.color, symbol: action.symbolName, total: total, type: type)
|
|
}
|
|
.sorted { $0.total > $1.total }
|
|
}
|
|
|
|
private func tagSummaryRows(for type: TrackingType) -> [SummaryRowItem] {
|
|
let stats = tagStats(in: spanRange)
|
|
let rows: [SummaryRowItem] = switch type {
|
|
case .time:
|
|
stats.filter { $0.seconds > 0 }
|
|
.map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: $0.seconds, type: .time) }
|
|
case .count:
|
|
stats.filter { $0.count > 0 }
|
|
.map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: Double($0.count), type: .count) }
|
|
}
|
|
return rows.sorted { $0.total > $1.total }
|
|
}
|
|
|
|
private func averageLabel(_ value: Double, type: TrackingType) -> String {
|
|
switch type {
|
|
case .time: return Format.durationShort(value)
|
|
case .count: return String(format: "%.1f회", value)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func summaryTableCard(_ title: String, timeRows: [SummaryRowItem], countRows: [SummaryRowItem]) -> some View {
|
|
chartCard(title) {
|
|
if timeRows.isEmpty && countRows.isEmpty {
|
|
emptyChartText
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
if !timeRows.isEmpty {
|
|
summaryGrid(sectionLabel: "시간", rows: timeRows)
|
|
}
|
|
if !timeRows.isEmpty && !countRows.isEmpty {
|
|
Divider()
|
|
}
|
|
if !countRows.isEmpty {
|
|
summaryGrid(sectionLabel: "횟수", rows: countRows)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func summaryGrid(sectionLabel: String, rows: [SummaryRowItem]) -> some View {
|
|
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) {
|
|
GridRow {
|
|
Text(sectionLabel)
|
|
.fontWeight(.semibold)
|
|
Text("합계")
|
|
.gridColumnAlignment(.trailing)
|
|
Text("하루 평균")
|
|
.gridColumnAlignment(.trailing)
|
|
if span == .month {
|
|
Text("주 평균")
|
|
.gridColumnAlignment(.trailing)
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
ForEach(rows) { row in
|
|
GridRow {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: row.symbol)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 24, height: 24)
|
|
.background(row.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
Text(row.name)
|
|
.font(.footnote)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(valueLabel(row.total, type: row.type))
|
|
.font(.footnote.weight(.semibold).monospacedDigit())
|
|
.foregroundStyle(AppTheme.green)
|
|
Text(averageLabel(row.total / Double(elapsedDayCount), type: row.type))
|
|
.font(.footnote.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
if span == .month {
|
|
Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type))
|
|
.font(.footnote.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|