mycode/myApp/HaruDanim/Shared/Models.swift
songyc macbook 85a98c1ab0 feat(goal): configurable achievement threshold per goal
목표 종료 판정이 '다짐 전체 달성'으로 고정되어 있던 것을, 목표를 만들 때
달성 판정 기준(%)을 정할 수 있게 확장. 다짐들의 평균 달성률(달성=100%,
미달성=현재 주기 진행률)이 기준 이상이면 달성 완료로 판정한다.

- Goal.achieveThresholdPercent (기본 100 = 기존 동작 유지, CloudKit 호환)
- evaluateIfEnded/manualFinish가 questAchievementRatio ≥ 기준으로 판정
- 목표 추가/수정 화면에 '달성 판정 기준' 슬라이더 (10~100%, 5% 단위)
- 수동 종료 안내 문구에 기준이 100% 미만이면 해당 기준 표기
- 검증용 런치 인자 -goalShowEditor 추가
- 그 외 진행률 표시·연산은 변경 없음

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 18:24:05 +09:00

477 lines
15 KiB
Swift

//
// 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
/// (%). ' '.
/// 100 ( CloudKit ).
var achieveThresholdPercent: Int = 100
/// [] (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))"
}
}
}