// // Models.swift // Haru_Danim // // SwiftData 데이터 모델 정의 (CLAUDE.md §10 참고) // import Foundation import SwiftData import SwiftUI // MARK: - 추적 방식 enum TrackingType: String, CaseIterable, Identifiable { case time case count var id: String { rawValue } var label: String { switch self { case .time: return "시간 측정" case .count: return "횟수 기록" } } var symbol: String { switch self { case .time: return "timer" case .count: return "number" } } } // MARK: - 꼬리표 (Tag) @Model final class Tag { var name: String = "" var colorHex: String = "#2F6B4F" var createdAt: Date = Date() var actions: [Action] = [] @Relationship(deleteRule: .nullify, inverse: \Quest.targetTag) var quests: [Quest] = [] init(name: String, colorHex: String) { self.name = name self.colorHex = colorHex self.createdAt = .now } } extension Tag { var color: Color { Color(hex: colorHex) } var sortedActions: [Action] { actions.sorted { $0.sortOrder < $1.sortOrder } } } // MARK: - 행동 (Action) @Model final class Action { var name: String = "" var symbolName: String = "star.fill" var trackingTypeRaw: String = TrackingType.time.rawValue var sortOrder: Int = 0 var createdAt: Date = Date() @Relationship(inverse: \Tag.actions) var tags: [Tag] = [] @Relationship(deleteRule: .cascade, inverse: \TimeSession.action) var sessions: [TimeSession] = [] @Relationship(deleteRule: .cascade, inverse: \CountEntry.action) var countEntries: [CountEntry] = [] @Relationship(deleteRule: .nullify, inverse: \Quest.targetAction) var quests: [Quest] = [] init(name: String, symbolName: String, trackingType: TrackingType, sortOrder: Int) { self.name = name self.symbolName = symbolName self.trackingTypeRaw = trackingType.rawValue self.sortOrder = sortOrder self.createdAt = .now } } extension Action { var trackingType: TrackingType { get { TrackingType(rawValue: trackingTypeRaw) ?? .time } set { trackingTypeRaw = newValue.rawValue } } /// 행동 버튼의 색: 지정된 꼬리표(가장 먼저 만든 것)의 색을 따른다. var color: Color { tags.sorted { $0.createdAt < $1.createdAt }.first?.color ?? AppTheme.green } var sortedTags: [Tag] { tags.sorted { $0.createdAt < $1.createdAt } } /// 진행 중(종료되지 않은) 세션 var runningSession: TimeSession? { sessions.first { $0.endAt == nil } } var isRunning: Bool { runningSession != nil } } // MARK: - 시간 기록 (TimeSession) @Model final class TimeSession { var startAt: Date = Date() var endAt: Date? var action: Action? init(action: Action, startAt: Date, endAt: Date? = nil) { self.action = action self.startAt = startAt self.endAt = endAt } } extension TimeSession { func duration(asOf now: Date = .now) -> TimeInterval { max(0, (endAt ?? now).timeIntervalSince(startAt)) } } // MARK: - 횟수 기록 (CountEntry) @Model final class CountEntry { var timestamp: Date = Date() var amount: Int = 1 var action: Action? init(action: Action, timestamp: Date, amount: Int = 1) { self.action = action self.timestamp = timestamp self.amount = amount } } // MARK: - 목표 (Goal) enum GoalStatus: String, CaseIterable { case inProgress case achieved case notAchieved var label: String { switch self { case .inProgress: return "진행 중" case .achieved: return "달성 완료" case .notAchieved: return "미달성 종료" } } } @Model final class Goal { var title: String = "" var symbolName: String = "flag.fill" var colorHex: String = "#2F6B4F" var startDate: Date = Date() var endDate: Date? var statusRaw: String = GoalStatus.inProgress.rawValue var createdAt: Date = Date() @Relationship(deleteRule: .cascade, inverse: \Quest.goal) var quests: [Quest] = [] init(title: String, symbolName: String, colorHex: String, startDate: Date, endDate: Date?) { self.title = title self.symbolName = symbolName self.colorHex = colorHex self.startDate = startDate self.endDate = endDate self.statusRaw = GoalStatus.inProgress.rawValue self.createdAt = .now } } extension Goal { var status: GoalStatus { get { GoalStatus(rawValue: statusRaw) ?? .inProgress } set { statusRaw = newValue.rawValue } } var color: Color { Color(hex: colorHex) } var sortedQuests: [Quest] { quests.sorted { $0.createdAt < $1.createdAt } } /// 종료일이 지났는지 (종료일 당일까지는 진행 중으로 취급) func isPastEndDate(asOf now: Date = .now, calendar: Calendar = .current) -> Bool { guard let endDate else { return false } let endOfDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDate))! return now >= endOfDay } /// 다짐이 하나도 없는 채 종료일이 도래 → "확인 필요" 상태 (CLAUDE.md §6.4-4) func needsConfirmation(asOf now: Date = .now) -> Bool { status == .inProgress && quests.isEmpty && isPastEndDate(asOf: now) } /// 종료일이 있는 목표의 기간 경과 진행률 (0...1) func dateProgress(asOf now: Date = .now) -> Double? { guard let endDate, endDate > startDate else { return nil } let total = endDate.timeIntervalSince(startDate) let elapsed = now.timeIntervalSince(startDate) return min(max(elapsed / total, 0), 1) } } // MARK: - 다짐 (Quest) enum QuestPeriod: String, CaseIterable, Identifiable { case daily case weekly case monthly case custom var id: String { rawValue } var label: String { switch self { case .daily: return "하루 단위" case .weekly: return "일주일 단위" case .monthly: return "한 달 단위" case .custom: return "특정 기간" } } } /// 하루 단위 다짐의 적용일 방식 enum QuestScheduleMode: String, CaseIterable, Identifiable { case everyDay case weekdays case monthDays case ordinalWeekday var id: String { rawValue } var label: String { switch self { case .everyDay: return "매일" case .weekdays: return "요일 지정" case .monthDays: return "날짜 지정" case .ordinalWeekday: return "몇째 주 요일" } } } enum QuestDirection: String, CaseIterable, Identifiable { case atLeast case atMost var id: String { rawValue } var label: String { switch self { case .atLeast: return "이상 달성" case .atMost: return "이하 유지" } } } @Model final class Quest { var goal: Goal? /// 대상: 행동 또는 꼬리표 중 하나 var targetAction: Action? var targetTag: Tag? /// 측정 기준 (행동 대상이면 그 행동의 추적 방식, 꼬리표 대상이면 생성 시 선택) var measureRaw: String = TrackingType.time.rawValue var periodRaw: String = QuestPeriod.daily.rawValue var scheduleModeRaw: String = QuestScheduleMode.everyDay.rawValue /// Calendar.weekday 값 (1=일 ... 7=토) var weekdays: [Int] = [] /// 1~31 var monthDays: [Int] = [] /// 몇째 주 (1~5) var ordinalWeek: Int = 1 /// Calendar.weekday 값 var ordinalWeekday: Int = 2 var customStart: Date? var customEnd: Date? /// 시간 목표(초) — measure == .time 일 때 사용 var targetSeconds: Double = 3600 /// 횟수 목표 — measure == .count 일 때 사용 var targetCount: Int = 1 var directionRaw: String = QuestDirection.atLeast.rawValue var createdAt: Date = Date() init(goal: Goal) { self.goal = goal self.createdAt = .now } } extension Quest { var measure: TrackingType { get { TrackingType(rawValue: measureRaw) ?? .time } set { measureRaw = newValue.rawValue } } var period: QuestPeriod { get { QuestPeriod(rawValue: periodRaw) ?? .daily } set { periodRaw = newValue.rawValue } } var scheduleMode: QuestScheduleMode { get { QuestScheduleMode(rawValue: scheduleModeRaw) ?? .everyDay } set { scheduleModeRaw = newValue.rawValue } } var direction: QuestDirection { get { QuestDirection(rawValue: directionRaw) ?? .atLeast } set { directionRaw = newValue.rawValue } } var targetName: String { if let targetAction { return targetAction.name } if let targetTag { return "#\(targetTag.name)" } return "대상 없음" } var targetSymbol: String { if let targetAction { return targetAction.symbolName } return "tag.fill" } var targetColor: Color { if let targetAction { return targetAction.color } if let targetTag { return targetTag.color } return AppTheme.green } /// 다짐이 집계 대상으로 삼는 행동들 var targetActions: [Action] { if let targetAction { return [targetAction] } if let targetTag { return targetTag.actions.filter { $0.trackingType == measure } } return [] } /// 목표량 (시간이면 초, 횟수면 개수) var targetValue: Double { measure == .time ? targetSeconds : Double(targetCount) } var targetValueLabel: String { measure == .time ? Format.durationShort(targetSeconds) : "\(targetCount)회" } /// 주기 요약 문구 (예: "매주 월·수·금") var scheduleLabel: String { switch period { case .daily: switch scheduleMode { case .everyDay: return "매일" case .weekdays: let names = weekdays.sorted().map { Format.weekdayShort($0) } return "매주 " + names.joined(separator: "·") case .monthDays: let names = monthDays.sorted().map { "\($0)일" } return "매달 " + names.joined(separator: ", ") case .ordinalWeekday: return "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일" } case .weekly: return "매주" case .monthly: return "매달" case .custom: guard let customStart, let customEnd else { return "특정 기간" } return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))" } } }