songyc macbook c35f50fa73 feat: add Goal(목표), Quest(다짐), and history tracking features
- Implement Goal(목표) and Quest(다짐) functionality
- Add a new feature for tracking and recording history
- TODO: Fix an issue in 횟수측정 Action(행동) where count and time are recorded but not reflected in the icon
2026-07-07 02:37:16 +09:00

291 lines
7.4 KiB
Swift

//
// Schema.swift
// HaruDanim
//
// SwiftData schema definitions: models, enums, and relationships.
//
import Foundation
import SwiftData
// MARK: - Enums
/// Determines how an `Action` is tracked.
enum ActionType: String, Codable, CaseIterable {
/// Tracked via elapsed time (`TimeSession`).
case time
/// Tracked via discrete counts (`CountEntry`).
case count
}
/// Lifecycle state of a `Goal`.
enum GoalStatus: String, Codable, CaseIterable {
case inProgress
case achieved
case failed
/// Awaiting user confirmation (e.g. period ended, result ambiguous).
case needsConfirmation
}
/// The recurrence window a `Quest` is evaluated over.
enum QuestPeriod: String, Codable, CaseIterable {
case daily
case weekly
case monthly
case custom
}
/// Whether the target value should be met by going above or below it.
enum QuestDirection: String, Codable, CaseIterable {
/// Succeed when the measured value is at or above the target.
case above
/// Succeed when the measured value is at or below the target.
case below
}
// MARK: - Tag
@Model
final class Tag {
@Attribute(.unique) var id: UUID
var name: String
/// Hex color string (e.g. `#2F6B4F`) for tag styling.
var colorHex: String
var createdAt: Date
/// Actions carrying this tag (many-to-many). Inverse declared on `Action.tags`.
var actions: [Action]
init(
id: UUID = UUID(),
name: String,
colorHex: String = "#2F6B4F",
createdAt: Date = .now,
actions: [Action] = []
) {
self.id = id
self.name = name
self.colorHex = colorHex
self.createdAt = createdAt
self.actions = actions
}
}
// MARK: - Action
@Model
final class Action {
@Attribute(.unique) var id: UUID
var name: String
var typeRaw: String
/// SF Symbol name used for iconography.
var iconName: String
var createdAt: Date
/// Tags applied to this action (many-to-many, owning side of the inverse).
@Relationship(inverse: \Tag.actions)
var tags: [Tag]
/// Time-based tracking records. Deleting the action cascades to its sessions.
@Relationship(deleteRule: .cascade, inverse: \TimeSession.action)
var timeSessions: [TimeSession]
/// Count-based tracking records. Deleting the action cascades to its entries.
@Relationship(deleteRule: .cascade, inverse: \CountEntry.action)
var countEntries: [CountEntry]
var type: ActionType {
get { ActionType(rawValue: typeRaw) ?? .time }
set { typeRaw = newValue.rawValue }
}
init(
id: UUID = UUID(),
name: String,
type: ActionType = .time,
iconName: String = "circle",
createdAt: Date = .now,
tags: [Tag] = [],
timeSessions: [TimeSession] = [],
countEntries: [CountEntry] = []
) {
self.id = id
self.name = name
self.typeRaw = type.rawValue
self.iconName = iconName
self.createdAt = createdAt
self.tags = tags
self.timeSessions = timeSessions
self.countEntries = countEntries
}
}
// MARK: - TimeSession
@Model
final class TimeSession {
@Attribute(.unique) var id: UUID
var startTime: Date
/// `nil` while the session is still running.
var endTime: Date?
/// The action this session belongs to (inverse of `Action.timeSessions`).
var action: Action?
init(
id: UUID = UUID(),
startTime: Date = .now,
endTime: Date? = nil,
action: Action? = nil
) {
self.id = id
self.startTime = startTime
self.endTime = endTime
self.action = action
}
}
// MARK: - CountEntry
@Model
final class CountEntry {
@Attribute(.unique) var id: UUID
var timestamp: Date
/// Number of units recorded in this entry.
var amount: Int
/// The action this entry belongs to (inverse of `Action.countEntries`).
var action: Action?
init(
id: UUID = UUID(),
timestamp: Date = .now,
amount: Int = 1,
action: Action? = nil
) {
self.id = id
self.timestamp = timestamp
self.amount = amount
self.action = action
}
}
// MARK: - Goal
@Model
final class Goal {
@Attribute(.unique) var id: UUID
var title: String
var statusRaw: String
/// Hex color string (e.g. `#2F6B4F`) for goal styling.
var colorHex: String
/// Inclusive date the goal begins being tracked.
var startDate: Date
/// Inclusive date the goal is evaluated through.
var endDate: Date
var createdAt: Date
/// Quests composing this goal. Deleting the goal cascades to its quests.
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
var quests: [Quest]
var status: GoalStatus {
get { GoalStatus(rawValue: statusRaw) ?? .inProgress }
set { statusRaw = newValue.rawValue }
}
init(
id: UUID = UUID(),
title: String,
status: GoalStatus = .inProgress,
colorHex: String = "#2F6B4F",
startDate: Date = .now,
endDate: Date = .now,
createdAt: Date = .now,
quests: [Quest] = []
) {
self.id = id
self.title = title
self.statusRaw = status.rawValue
self.colorHex = colorHex
self.startDate = startDate
self.endDate = endDate
self.createdAt = createdAt
self.quests = quests
}
}
// MARK: - Quest
@Model
final class Quest {
@Attribute(.unique) var id: UUID
var title: String
var periodRaw: String
var directionRaw: String
/// The threshold this quest is measured against (seconds for time, units for count).
var targetValue: Double
/// Whether `targetValue` is expressed in seconds (`true`) or discrete units (`false`).
var usesTime: Bool
var createdAt: Date
/// The goal this quest belongs to (inverse of `Goal.quests`).
var goal: Goal?
/// The `Action` this quest measures, when it targets a single action.
var targetAction: Action?
/// The `Tag` this quest measures, when it targets a tag's aggregate.
var targetTag: Tag?
var period: QuestPeriod {
get { QuestPeriod(rawValue: periodRaw) ?? .daily }
set { periodRaw = newValue.rawValue }
}
var direction: QuestDirection {
get { QuestDirection(rawValue: directionRaw) ?? .above }
set { directionRaw = newValue.rawValue }
}
init(
id: UUID = UUID(),
title: String,
period: QuestPeriod = .daily,
direction: QuestDirection = .above,
targetValue: Double = 0,
usesTime: Bool = true,
createdAt: Date = .now,
goal: Goal? = nil,
targetAction: Action? = nil,
targetTag: Tag? = nil
) {
self.id = id
self.title = title
self.periodRaw = period.rawValue
self.directionRaw = direction.rawValue
self.targetValue = targetValue
self.usesTime = usesTime
self.createdAt = createdAt
self.goal = goal
self.targetAction = targetAction
self.targetTag = targetTag
}
}
// MARK: - AppSettings
@Model
final class AppSettings {
@Attribute(.unique) var id: UUID
/// Hour (0...23) at which a new logical day begins. Default 00:00.
var dayStartHour: Int
init(
id: UUID = UUID(),
dayStartHour: Int = 0
) {
self.id = id
self.dayStartHour = dayStartHour
}
}