// // HistoryView.swift // Haru_Danim // // 기록 탭: 일간 목록 / 타임테이블(하루·일주일) (CLAUDE.md §6.5 — 통계는 통계 탭으로 분리) // import SwiftUI import SwiftData enum HistoryMode: String, CaseIterable, Identifiable { case list, timetable var id: String { rawValue } var label: String { switch self { case .list: return String(localized: "목록") case .timetable: return String(localized: "타임테이블") } } } // MARK: - 표시용 아이템 struct SessionSegmentItem: Identifiable { let session: TimeSession let action: Action let range: Range var id: String { "\(String(describing: session.persistentModelID))-\(range.lowerBound.timeIntervalSinceReferenceDate)" } var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) } } struct CountItem: Identifiable { let entry: CountEntry let action: Action var id: String { String(describing: entry.persistentModelID) } } // MARK: - 기록 탭 본체 struct HistoryView: 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 selectedDayKey: Date = { let math = DayMath() #if DEBUG // 검증·마케팅 촬영용: -historyDaysAgo N → N일 전 날짜로 시작 (지난주 등 과거 주 표시) let daysAgo = UserDefaults.standard.integer(forKey: "historyDaysAgo") if daysAgo > 0 { return math.calendar.date(byAdding: .day, value: -daysAgo, to: math.dayKey(for: .now))! } #endif return math.dayKey(for: .now) }() @State private var mode: HistoryMode = { #if DEBUG if let raw = UserDefaults.standard.string(forKey: "historyMode"), let mode = HistoryMode(rawValue: raw) { return mode } #endif return .list }() @State private var timetableWeekly: Bool = { #if DEBUG // 검증용: -historyWeekly YES → 타임테이블 일주일 보기로 시작 if UserDefaults.standard.bool(forKey: "historyWeekly") { return true } #endif return false }() @State private var editingSession: TimeSession? @State private var editingEntry: CountEntry? @State private var showingCalendar = false @State private var showingFilter = false @State private var showingExport = false /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택 (통계 탭과 동일한 모델) @State private var excludedActionIDs: Set = [] private var math: DayMath { DayMath() } private var dayRange: Range { math.dayRange(forKey: selectedDayKey) } var body: some View { VStack(spacing: 0) { dateHeader Picker("보기", selection: $mode) { ForEach(HistoryMode.allCases) { m in Text(m.label).tag(m) } } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 8) if isFiltering { filterChip } // 기록 조회는 선택한 날짜가 속한 주 범위로만 DB에서 가져온다 (전량 로드 방지). // dayKey가 바뀌면 새 범위의 쿼리로 다시 만들어진다 (SwiftData 동적 쿼리 패턴). HistoryRecordsView( dayKey: selectedDayKey, mode: mode, timetableWeekly: $timetableWeekly, excludedActionIDs: excludedActionIDs, orderedActions: allActions, showingExport: $showingExport, onTapSession: { editingSession = $0 }, onTapEntry: { editingEntry = $0 } ) } .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(item: $editingSession) { session in SessionEditorView(session: session) } .sheet(item: $editingEntry) { entry in CountEntryEditorView(entry: entry) } .sheet(isPresented: $showingFilter) { RecordFilterSheet( title: "기록 필터", tags: tags, actions: allActions, excludedActionIDs: $excludedActionIDs ) } .onAppear { consumePendingFilter() #if DEBUG // 검증용: -excludeActions "이름,이름"으로 필터 상태 주입, -historyShowFilter 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: "historyShowFilter") { showingFilter = true } // 검증용: -showExport YES → 내보내기 시트 표시 if UserDefaults.standard.bool(forKey: "showExport") { showingExport = true } // 검증용: -recordShowEditor session|count → 최신 기록의 수정 시트 표시 switch UserDefaults.standard.string(forKey: "recordShowEditor") { case "session": editingSession = allActions.flatMap(\.sessions).sorted { $0.startAt > $1.startAt }.first case "count": editingEntry = allActions.flatMap(\.countEntries).sorted { $0.timestamp > $1.timestamp }.first default: break } #endif } .onChange(of: router.pendingHistoryActionID) { consumePendingFilter() } } /// 모음 탭 '기록 확인'에서 예약한 행동 필터를 적용 (오늘 날짜의 목록 뷰로 전환) private func consumePendingFilter() { guard let id = router.pendingHistoryActionID else { return } // 해당 행동만 남기고 모두 해제 excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id]) mode = .list selectedDayKey = math.dayKey(for: .now) router.pendingHistoryActionID = nil } // MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택) private var isFiltering: Bool { // 삭제된 행동의 잔존 제외 ID는 무시 — 실재 행동이 하나라도 제외됐을 때만 필터 활성 // (안 그러면 제외했던 행동을 삭제한 뒤에도 칩·내보내기 필터 문구가 허위로 남는다) allActions.contains { !passesFilter($0) } } private func passesFilter(_ action: Action) -> Bool { !excludedActionIDs.contains(action.persistentModelID) } private var filterLabel: String { let names = allActions.filter(passesFilter).map(\.name) if names.isEmpty { return String(localized: "표시할 행동 없음") } return names.count <= 2 ? names.joined(separator: ", ") : String(localized: "행동 \(names.count)/\(allActions.count)개 표시 중") } private var filterChip: some View { HStack(spacing: 6) { Image(systemName: "line.3.horizontal.decrease") .font(.caption2.weight(.semibold)) Text(filterLabel) .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: 날짜 이동 헤더 (날짜를 누르면 달력 팝업) private var dateHeader: some View { HStack { Button { moveDay(-1) } label: { Image(systemName: "chevron.left") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("이전 날짜")) Spacer() Button { showingCalendar = true } label: { VStack(spacing: 1) { HStack(spacing: 4) { Text(Format.fullDate(selectedDayKey)) .font(.headline) .foregroundStyle(.primary) Image(systemName: "chevron.down.circle.fill") .font(.caption) .foregroundStyle(AppTheme.green) } if math.dayKey(for: .now) == selectedDayKey { Text("오늘") .font(.caption2) .foregroundStyle(AppTheme.green) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .popover(isPresented: $showingCalendar) { DatePicker( "날짜 선택", selection: calendarSelection, displayedComponents: .date ) .datePickerStyle(.graphical) .frame(minWidth: 320, minHeight: 340) .padding(8) .presentationCompactAdaptation(.popover) } Spacer() Button { moveDay(1) } label: { Image(systemName: "chevron.right") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("다음 날짜")) Button("오늘") { selectedDayKey = math.dayKey(for: .now) } .font(.caption) .buttonStyle(.bordered) .buttonBorderShape(.capsule) } .padding(.horizontal) .padding(.vertical, 10) .tint(AppTheme.green) } /// 달력 팝업 선택값 ↔ 논리적 하루 키 변환. /// 키(dayKey)는 "해당 달력일의 자정"이므로 고른 달력일의 startOfDay를 키로 직접 쓴다 — /// 하루 시작 시간이 몇 시든 고른 날짜가 그대로 표시·조회된다 (일기 내보내기와 같은 규칙, §4.1). /// 예전의 정오 경유(dayKey(for: 12:00))는 하루 시작이 오후면 전날로 밀리는 엣지가 있었다. private var calendarSelection: Binding { Binding { selectedDayKey } set: { picked in selectedDayKey = math.calendar.startOfDay(for: picked) showingCalendar = false } } /// 날짜 이동 스텝은 보기 모드와 연동한다. /// 타임테이블 '일주일' 보기에서는 한 번에 한 주(7일)씩 점프해 즉시 이전/다음 주가 보인다. /// (목록·하루 타임테이블은 하루씩, 달력 팝업/‘오늘’로 날짜를 직접 선택하는 동작에는 영향 없음) private func moveDay(_ direction: Int) { let step = (mode == .timetable && timetableWeekly) ? 7 : 1 selectedDayKey = math.calendar.date(byAdding: .day, value: direction * step, to: selectedDayKey)! } } // MARK: - 기록 조회·표시 (선택 주간 범위로만 DB 조회) /// 선택한 날짜가 속한 **주** 범위의 기록만 가져오는 하위 뷰. /// 예전에는 전체 TimeSession/CountEntry를 @Query로 올려 메모리에서 걸렀는데, /// 기록이 수만 건으로 쌓이면 탭 진입·날짜 이동이 느려지는 구조라 범위 조회로 바꿨다. /// - 주 범위인 이유: 목록(하루)과 타임테이블(하루/일주일) 두 모드를 한 쿼리로 덮는다. /// - 겹침 조건: 하루 경계를 걸친 세션·진행 중 세션(endAt nil)을 놓치지 않도록 /// "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)"으로 가져온다. private struct HistoryRecordsView: View { let dayKey: Date let mode: HistoryMode @Binding var timetableWeekly: Bool let excludedActionIDs: Set let orderedActions: [Action] @Binding var showingExport: Bool let onTapSession: (TimeSession) -> Void let onTapEntry: (CountEntry) -> Void @Query private var sessions: [TimeSession] @Query private var entries: [CountEntry] private let math = DayMath() init(dayKey: Date, mode: HistoryMode, timetableWeekly: Binding, excludedActionIDs: Set, orderedActions: [Action], showingExport: Binding, onTapSession: @escaping (TimeSession) -> Void, onTapEntry: @escaping (CountEntry) -> Void) { self.dayKey = dayKey self.mode = mode self._timetableWeekly = timetableWeekly self.excludedActionIDs = excludedActionIDs self.orderedActions = orderedActions self._showingExport = showingExport self.onTapSession = onTapSession self.onTapEntry = onTapEntry let math = DayMath() let week = math.weekRange(containing: math.dayRange(forKey: dayKey).lowerBound) let lower = week.lowerBound let upper = week.upperBound let farFuture = Date.distantFuture _sessions = Query(filter: #Predicate { $0.startAt < upper && ($0.endAt ?? farFuture) > lower }) _entries = Query(filter: #Predicate { $0.timestamp >= lower && $0.timestamp < upper }) } private var dayRange: Range { math.dayRange(forKey: dayKey) } var body: some View { Group { switch mode { case .list: listView case .timetable: TimetableView( weekly: $timetableWeekly, selectedDayKey: dayKey, segments: segments(in:), counts: counts(in:), math: math, onTapSession: onTapSession, onTapEntry: onTapEntry ) } } .sheet(isPresented: $showingExport) { // 지금 보고 있는 날짜/보기 모드/필터 그대로 스냅숏을 만들어 이미지로 내보낸다 // (스냅숏 집계 범위는 하루 또는 이 주간 — 조회 범위와 일치) ExportImageSheet(snapshot: ExportBuilder.history( dayKey: dayKey, weeklyTimetable: mode == .timetable && timetableWeekly, sessions: sessions, entries: entries, orderedActions: orderedActions, excludedActionIDs: excludedActionIDs, math: math )) } } private func passesFilter(_ action: Action) -> Bool { !excludedActionIDs.contains(action.persistentModelID) } // 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) } .sorted { $0.entry.timestamp < $1.entry.timestamp } } // MARK: 일간 목록 뷰 private var listView: some View { let daySegments = segments(in: dayRange) let dayCounts = counts(in: dayRange) return List { Section("시간 기록") { if daySegments.isEmpty { Text("시간 기록이 없어요").foregroundStyle(.secondary) } ForEach(daySegments) { item in Button { onTapSession(item.session) } label: { HStack(spacing: 10) { Image(safeSymbol: item.action.symbolName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) .frame(width: 30, height: 30) .background(item.action.color, in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 1) { Text(item.action.name) .font(.subheadline.weight(.medium)) Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? String(localized: "진행 중") : Format.time(item.range.upperBound))") .font(.caption) .foregroundStyle(.secondary) if !item.session.note.isEmpty { Label(item.session.note, systemImage: "note.text") .font(.caption) .foregroundStyle(AppTheme.yellow) .lineLimit(2) .padding(.top, 1) } } Spacer() Text(Format.durationShort(item.duration)) .font(.subheadline.weight(.semibold)) .foregroundStyle(AppTheme.green) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } } Section("횟수 기록") { if dayCounts.isEmpty { Text("횟수 기록이 없어요").foregroundStyle(.secondary) } ForEach(dayCounts) { item in Button { onTapEntry(item.entry) } label: { HStack(spacing: 10) { Image(safeSymbol: item.action.symbolName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) .frame(width: 30, height: 30) .background(item.action.color, in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 1) { Text(item.action.name) .font(.subheadline.weight(.medium)) Text(Format.time(item.entry.timestamp)) .font(.caption) .foregroundStyle(.secondary) if !item.entry.note.isEmpty { Label(item.entry.note, systemImage: "note.text") .font(.caption) .foregroundStyle(AppTheme.yellow) .lineLimit(2) .padding(.top, 1) } } Spacer() Text("+\(item.entry.amount)") .font(.subheadline.weight(.semibold)) .foregroundStyle(AppTheme.green) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } } } .scrollContentBackground(.hidden) } } // MARK: - 타임테이블 뷰 struct TimetableView: View { @Binding var weekly: Bool let selectedDayKey: Date let segments: (Range) -> [SessionSegmentItem] let counts: (Range) -> [CountItem] let math: DayMath let onTapSession: (TimeSession) -> Void let onTapEntry: (CountEntry) -> Void private var hourHeight: CGFloat { weekly ? 26 : 48 } var body: some View { VStack(spacing: 8) { Picker("범위", selection: $weekly) { Text("하루").tag(false) Text("일주일").tag(true) } .pickerStyle(.segmented) .padding(.horizontal) ScrollView { HStack(alignment: .top, spacing: 4) { hourLabels if weekly { weekColumns } else { dayColumn(for: selectedDayKey) .frame(maxWidth: .infinity) } } .padding(.horizontal) .padding(.bottom, 20) } } } private var startHour: Int { let start = math.dayRange(forKey: selectedDayKey).lowerBound return math.calendar.component(.hour, from: start) } private var hourLabels: some View { // 하루 시작이 정시가 아니면(예 06:30) 행 경계도 06:30~07:30이라 시(hour)만 쓰면 // 라벨과 실제 위치가 최대 59분 어긋난다 — 분 성분까지 표기 (내보내기와 동일 규칙) let startMinute = math.calendar.component(.minute, from: math.dayRange(forKey: selectedDayKey).lowerBound) return VStack(alignment: .trailing, spacing: 0) { ForEach(0..<24, id: \.self) { i in Text(startMinute == 0 ? String(format: "%02d", (startHour + i) % 24) : String(format: "%02d:%02d", (startHour + i) % 24, startMinute)) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .frame(height: hourHeight, alignment: .top) } } .frame(width: startMinute == 0 ? 26 : 36) } private var weekColumns: some View { let weekRange = math.weekRange(containing: math.dayRange(forKey: selectedDayKey).lowerBound) let keys = math.dayKeys(in: weekRange) return HStack(alignment: .top, spacing: 3) { ForEach(keys, id: \.self) { key in VStack(spacing: 2) { Text(Format.weekdayShort(math.calendar.component(.weekday, from: key))) .font(.caption2) .foregroundStyle(key == selectedDayKey ? AppTheme.green : .secondary) dayColumn(for: key) } .frame(maxWidth: .infinity) } } } private func dayColumn(for key: Date) -> some View { let range = math.dayRange(forKey: key) let daySegments = segments(range) let dayCounts = counts(range) return GeometryReader { geo in ZStack(alignment: .topLeading) { gridLines(width: geo.size.width) ForEach(daySegments) { item in block(item, in: range, width: geo.size.width) } ForEach(Array(dayCounts.enumerated()), id: \.element.id) { index, item in marker(item, in: range, width: geo.size.width, index: index) } } } .frame(height: hourHeight * 24) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } private func gridLines(width: CGFloat) -> some View { VStack(spacing: 0) { ForEach(0..<24, id: \.self) { _ in Divider().opacity(0.4) Spacer(minLength: 0) } } .frame(width: width, height: hourHeight * 24) } private func yOffset(for date: Date, in range: Range) -> CGFloat { let seconds = date.timeIntervalSince(range.lowerBound) return CGFloat(seconds / 3600) * hourHeight } private func block(_ item: SessionSegmentItem, in range: Range, width: CGFloat) -> some View { let y = yOffset(for: item.range.lowerBound, in: range) let height = max(CGFloat(item.duration / 3600) * hourHeight, 8) return Button { onTapSession(item.session) } label: { RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(item.action.color.opacity(0.85)) .overlay(alignment: .topLeading) { if !weekly && height >= 18 { Label(item.action.name, systemImage: SymbolCompat.safe(item.action.symbolName)) .font(.caption2.weight(.semibold)) .foregroundStyle(.white) .lineLimit(1) .padding(4) } else if height >= 14 { Image(safeSymbol: item.action.symbolName) .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white) .padding(2) } } } .buttonStyle(.plain) // 색 블록만으로는 보이스오버가 읽을 내용이 없다 — 행동 이름과 시간대를 라벨로 .accessibilityLabel(Text(verbatim: "\(item.action.name) \(Format.time(item.range.lowerBound)) ~ \(Format.time(item.range.upperBound))")) .frame(width: max(width - 4, 8), height: height) .offset(x: 2, y: y) } private func marker(_ item: CountItem, in range: Range, width: CGFloat, index: Int) -> some View { let y = yOffset(for: item.entry.timestamp, in: range) let size: CGFloat = weekly ? 10 : 18 let x = 4 + CGFloat(index % 5) * (size + 4) return Button { onTapEntry(item.entry) } label: { ZStack { Circle() .fill(item.action.color) Circle() .strokeBorder(AppTheme.surface, lineWidth: 2) if !weekly { Image(safeSymbol: item.action.symbolName) .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white) } } .frame(width: size, height: size) } .buttonStyle(.plain) .accessibilityLabel(Text(verbatim: "\(item.action.name) +\(item.entry.amount) \(Format.time(item.entry.timestamp))")) .offset(x: min(x, width - size), y: max(y - size / 2, 0)) } }