- 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
291 lines
7.4 KiB
Swift
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
|
|
}
|
|
}
|