mycode/myApp/HaruDanim/IOS/Views/HistoryView.swift
songyc macbook c18e3c7c6f fix(review): 전체 코드 리뷰 1차 — 데이터 복구·통계 표시·기록 편집 결함 수정
'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.

[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
    다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
    조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
    1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
    지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
    (이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
    집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
    경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
    호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
    고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
    — 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
    표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
    필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
    in: 은 표시 제약만) — 저장 시 정규화

+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)

검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:05:16 +09:00

708 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<Date>
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 = DayMath().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<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { 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<Date> {
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<PersistentIdentifier>
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<Bool>,
excludedActionIDs: Set<PersistentIdentifier>, orderedActions: [Action],
showingExport: Binding<Bool>,
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<TimeSession> {
$0.startAt < upper && ($0.endAt ?? farFuture) > lower
})
_entries = Query(filter: #Predicate<CountEntry> {
$0.timestamp >= lower && $0.timestamp < upper
})
}
private var dayRange: Range<Date> { 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<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.sorted { $0.range.lowerBound < $1.range.lowerBound }
}
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) }
.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(systemName: 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(systemName: 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<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [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<Date>) -> CGFloat {
let seconds = date.timeIntervalSince(range.lowerBound)
return CGFloat(seconds / 3600) * hourHeight
}
private func block(_ item: SessionSegmentItem, in range: Range<Date>, 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: item.action.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(systemName: 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<Date>, 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(systemName: 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))
}
}