// // 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 String(localized: "시간 측정") case .count: return String(localized: "횟수 기록") } } var symbol: String { switch self { case .time: return "timer" case .count: return "number" } } } // MARK: - 꼬리표 (Tag) @Model final class Tag { /// 워치 연동 등에서 꼬리표를 식별하는 안정적인 ID var uuid: UUID = UUID() var name: String = "" var colorHex: String = "#2F6B4F" /// 행동 탭 그룹/꼬리표 탭 목록의 표시 순서 var sortOrder: Int = 0 var createdAt: Date = Date() // CloudKit 동기화 요건: 모든 관계는 optional이어야 함 → 저장은 optional, // 사용처는 아래 non-optional 접근자를 통해 기존 이름 그대로 사용 (originalName으로 기존 데이터 유지) @Relationship(originalName: "actions") var actionsStorage: [Action]? = [] @Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetTag) var questsStorage: [Quest]? = [] var actions: [Action] { get { actionsStorage ?? [] } set { actionsStorage = newValue } } var quests: [Quest] { get { questsStorage ?? [] } set { questsStorage = newValue } } 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 { /// 시리 단축어·위젯에서 행동을 식별하는 안정적인 ID var uuid: UUID = UUID() var name: String = "" var symbolName: String = "star.fill" var trackingTypeRaw: String = TrackingType.time.rawValue /// [레거시] 배치 순서는 기기별 로컬 설정(LocalPrefs.actionOrder)으로 이전. /// CloudKit 스키마 유지를 위해 남겨 두며, 로컬 순서가 없는 기기의 폴백 정렬로만 쓰인다. var sortOrder: Int = 0 /// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시 var isFavorite: Bool = false /// [레거시] 메모 창 표시 여부는 기기별 로컬 설정(LocalPrefs.notePromptActions)으로 이전. /// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다. var promptsForNote: Bool = false var createdAt: Date = Date() // CloudKit 요건에 따라 관계는 optional 저장 + non-optional 접근자 (originalName으로 기존 데이터 유지) @Relationship(originalName: "tags", inverse: \Tag.actionsStorage) var tagsStorage: [Tag]? = [] @Relationship(deleteRule: .cascade, originalName: "sessions", inverse: \TimeSession.action) var sessionsStorage: [TimeSession]? = [] @Relationship(deleteRule: .cascade, originalName: "countEntries", inverse: \CountEntry.action) var countEntriesStorage: [CountEntry]? = [] @Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetAction) var questsStorage: [Quest]? = [] var tags: [Tag] { get { tagsStorage ?? [] } set { tagsStorage = newValue } } var sessions: [TimeSession] { get { sessionsStorage ?? [] } set { sessionsStorage = newValue } } var countEntries: [CountEntry] { get { countEntriesStorage ?? [] } set { countEntriesStorage = newValue } } var quests: [Quest] { get { questsStorage ?? [] } set { questsStorage = newValue } } 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 note: String = "" 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 note: String = "" 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 String(localized: "진행 중") case .achieved: return String(localized: "달성 완료") case .notAchieved: return String(localized: "미달성 종료") } } } @Model final class Goal { /// 시리 단축어·위젯에서 목표를 식별하는 안정적인 ID var uuid: UUID = UUID() 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 /// [레거시] 접힘 상태는 기기별 로컬 설정(LocalPrefs.collapsedGoals)으로 이전. /// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다. var isCollapsed: Bool = false /// [레거시] 노출 여부는 기기별 로컬 설정(LocalPrefs.pinnedGoals)으로 이전. /// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다. var showsOnMain: Bool = false /// 목표 탭 리스트 정렬 순서 (드래그로 변경, 값이 같으면 createdAt 순) var sortOrder: Int = 0 var createdAt: Date = Date() // CloudKit 요건에 따라 관계는 optional 저장 + non-optional 접근자 (originalName으로 기존 데이터 유지) @Relationship(deleteRule: .cascade, originalName: "quests", inverse: \Quest.goal) var questsStorage: [Quest]? = [] var quests: [Quest] { get { questsStorage ?? [] } set { questsStorage = newValue } } 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 { if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder } return $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 String(localized: "하루 단위") case .weekly: return String(localized: "일주일 단위") case .monthly: return String(localized: "한 달 단위") case .custom: return String(localized: "특정 기간") } } } /// 하루 단위 다짐의 적용일 방식 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 String(localized: "매일") case .weekdays: return String(localized: "요일 지정") case .monthDays: return String(localized: "날짜 지정") case .ordinalWeekday: return String(localized: "몇째 주 요일") } } } enum QuestDirection: String, CaseIterable, Identifiable { case atLeast case atMost var id: String { rawValue } var label: String { switch self { case .atLeast: return String(localized: "이상 달성") case .atMost: return String(localized: "이하 유지") } } } @Model final class Quest { /// 시리 단축어·위젯에서 다짐을 식별하는 안정적인 ID var uuid: UUID = UUID() 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 sortOrder: Int = 0 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 String(localized: "대상 없음") } 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) : String(localized: "\(targetCount)회") } /// 주기 요약 문구 (예: "매주 월·수·금") var scheduleLabel: String { switch period { case .daily: switch scheduleMode { case .everyDay: return String(localized: "매일") case .weekdays: let names = weekdays.sorted().map { Format.weekdayShort($0) } return String(localized: "매주 \(names.joined(separator: "·"))") case .monthDays: let names = monthDays.sorted().map { String(localized: "\($0)일") } return String(localized: "매달 \(names.joined(separator: ", "))") case .ordinalWeekday: return String(localized: "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일") } case .weekly: return String(localized: "매주") case .monthly: return String(localized: "매달") case .custom: guard let customStart, let customEnd else { return String(localized: "특정 기간") } return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))" } } }