632 lines
22 KiB
Swift
632 lines
22 KiB
Swift
//
|
|
// HistoryView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 기록 탭: 일간 목록 / 타임테이블(하루·일주일) / 통계 (CLAUDE.md §6.5)
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
enum HistoryMode: String, CaseIterable, Identifiable {
|
|
case list, timetable, stats
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .list: return "목록"
|
|
case .timetable: return "타임테이블"
|
|
case .stats: return "통계"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
@Query private var sessions: [TimeSession]
|
|
@Query private var entries: [CountEntry]
|
|
|
|
@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 = false
|
|
@State private var statSpan: StatSpan = .day
|
|
@State private var editingSession: TimeSession?
|
|
@State private var editingEntry: CountEntry?
|
|
|
|
private var math: DayMath { DayMath() }
|
|
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
dateHeader
|
|
Picker("보기", selection: $mode) {
|
|
ForEach(HistoryMode.allCases) { m in
|
|
Text(m.label).tag(m)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 8)
|
|
|
|
switch mode {
|
|
case .list:
|
|
listView
|
|
case .timetable:
|
|
TimetableView(
|
|
weekly: $timetableWeekly,
|
|
selectedDayKey: selectedDayKey,
|
|
segments: segments(in:),
|
|
counts: counts(in:),
|
|
math: math,
|
|
onTapSession: { editingSession = $0 },
|
|
onTapEntry: { editingEntry = $0 }
|
|
)
|
|
case .stats:
|
|
StatsView(
|
|
statSpan: $statSpan,
|
|
selectedDayKey: selectedDayKey,
|
|
segments: segments(in:),
|
|
counts: counts(in:),
|
|
math: math
|
|
)
|
|
}
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle("기록")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.sheet(item: $editingSession) { session in
|
|
SessionEditorView(session: session)
|
|
}
|
|
.sheet(item: $editingEntry) { entry in
|
|
CountEntryEditorView(entry: entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: 날짜 이동 헤더
|
|
|
|
private var dateHeader: some View {
|
|
HStack {
|
|
Button {
|
|
moveDay(-1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
}
|
|
Spacer()
|
|
VStack(spacing: 1) {
|
|
Text(Format.fullDate(selectedDayKey))
|
|
.font(.headline)
|
|
if math.dayKey(for: .now) == selectedDayKey {
|
|
Text("오늘")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button {
|
|
moveDay(1)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
}
|
|
Button("오늘") {
|
|
selectedDayKey = math.dayKey(for: .now)
|
|
}
|
|
.font(.caption)
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 10)
|
|
.tint(AppTheme.green)
|
|
}
|
|
|
|
private func moveDay(_ delta: Int) {
|
|
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, to: selectedDayKey)!
|
|
}
|
|
|
|
// 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 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) }
|
|
}
|
|
.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 {
|
|
editingSession = 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 ? "진행 중" : Format.time(item.range.upperBound))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(Format.durationShort(item.duration))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
Section("횟수 기록") {
|
|
if dayCounts.isEmpty {
|
|
Text("횟수 기록이 없어요").foregroundStyle(.secondary)
|
|
}
|
|
ForEach(dayCounts) { item in
|
|
Button {
|
|
editingEntry = 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)
|
|
}
|
|
Spacer()
|
|
Text("+\(item.entry.amount)")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
.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 {
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
ForEach(0..<24, id: \.self) { i in
|
|
Text(String(format: "%02d", (startHour + i) % 24))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.frame(height: hourHeight, alignment: .top)
|
|
}
|
|
}
|
|
.frame(width: 26)
|
|
}
|
|
|
|
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)
|
|
.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)
|
|
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
|
|
}
|
|
}
|
|
|
|
// 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<Date>) -> [SessionSegmentItem]
|
|
let counts: (Range<Date>) -> [CountItem]
|
|
let math: DayMath
|
|
|
|
private var spanRange: Range<Date> {
|
|
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)
|
|
weeklyTrendCard
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// MARK: 태그별 집계
|
|
|
|
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 [("꼬리표 없음", 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 최근 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<String>()
|
|
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)
|
|
}
|
|
}
|