222 lines
6.8 KiB
Swift
222 lines
6.8 KiB
Swift
import Foundation
|
||
import SwiftData
|
||
|
||
/// Represents when a ritual is typically performed during the day.
|
||
/// Used for sorting and display purposes.
|
||
enum TimeOfDay: String, Codable, CaseIterable, Comparable {
|
||
case morning // Before 11am
|
||
case midday // 11am - 2pm
|
||
case afternoon // 2pm - 5pm
|
||
case evening // 5pm - 9pm
|
||
case night // After 9pm
|
||
case anytime // Flexible timing
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .morning: return String(localized: "Morning")
|
||
case .midday: return String(localized: "Midday")
|
||
case .afternoon: return String(localized: "Afternoon")
|
||
case .evening: return String(localized: "Evening")
|
||
case .night: return String(localized: "Night")
|
||
case .anytime: return String(localized: "Anytime")
|
||
}
|
||
}
|
||
|
||
/// Time range description for this time of day
|
||
var timeRange: String {
|
||
switch self {
|
||
case .morning: return String(localized: "Before 11am")
|
||
case .midday: return String(localized: "11am – 2pm")
|
||
case .afternoon: return String(localized: "2pm – 5pm")
|
||
case .evening: return String(localized: "5pm – 9pm")
|
||
case .night: return String(localized: "After 9pm")
|
||
case .anytime: return String(localized: "Always visible")
|
||
}
|
||
}
|
||
|
||
/// Combined display name with time range
|
||
var displayNameWithRange: String {
|
||
"\(displayName) (\(timeRange))"
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .morning: return "sunrise.fill"
|
||
case .midday: return "sun.max.fill"
|
||
case .afternoon: return "sun.haze.fill"
|
||
case .evening: return "sunset.fill"
|
||
case .night: return "moon.stars.fill"
|
||
case .anytime: return "clock.fill"
|
||
}
|
||
}
|
||
|
||
/// Sort order for displaying rituals by time of day
|
||
var sortOrder: Int {
|
||
switch self {
|
||
case .morning: return 0
|
||
case .midday: return 1
|
||
case .afternoon: return 2
|
||
case .evening: return 3
|
||
case .night: return 4
|
||
case .anytime: return 5
|
||
}
|
||
}
|
||
|
||
/// Comparable conformance for sorting
|
||
static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool {
|
||
lhs.sortOrder < rhs.sortOrder
|
||
}
|
||
|
||
/// Returns the current time period based on the hour of the day.
|
||
static func current(for date: Date = Date()) -> TimeOfDay {
|
||
let hour = Calendar.current.component(.hour, from: date)
|
||
switch hour {
|
||
case 0..<11: return .morning
|
||
case 11..<14: return .midday
|
||
case 14..<17: return .afternoon
|
||
case 17..<21: return .evening
|
||
default: return .night
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A ritual represents a persistent habit-building journey. It contains multiple
|
||
/// arcs, each representing a time-bound period with its own habits and completions.
|
||
/// This allows rituals to be renewed while preserving historical accuracy.
|
||
@Model
|
||
final class Ritual {
|
||
var id: UUID = UUID()
|
||
var title: String = ""
|
||
var theme: String = ""
|
||
var notes: String = ""
|
||
|
||
// Default duration for new arcs
|
||
var defaultDurationDays: Int = 28
|
||
|
||
// Scheduling
|
||
var timeOfDay: TimeOfDay = TimeOfDay.anytime
|
||
|
||
// Organization
|
||
var iconName: String = "sparkles"
|
||
var category: String = ""
|
||
|
||
/// Persisted sort order for deterministic ordering across CloudKit sync.
|
||
/// Used as secondary sort key after timeOfDay.
|
||
var sortIndex: Int = 0
|
||
|
||
// Arcs - each arc represents a time-bound period with its own habits
|
||
@Relationship(deleteRule: .cascade)
|
||
var arcs: [RitualArc]?
|
||
|
||
init(
|
||
id: UUID = UUID(),
|
||
title: String,
|
||
theme: String,
|
||
defaultDurationDays: Int = 28,
|
||
notes: String = "",
|
||
timeOfDay: TimeOfDay = .anytime,
|
||
iconName: String = "sparkles",
|
||
category: String = "",
|
||
sortIndex: Int = 0,
|
||
arcs: [RitualArc] = []
|
||
) {
|
||
self.id = id
|
||
self.title = title
|
||
self.theme = theme
|
||
self.defaultDurationDays = defaultDurationDays
|
||
self.notes = notes
|
||
self.timeOfDay = timeOfDay
|
||
self.iconName = iconName
|
||
self.category = category
|
||
self.sortIndex = sortIndex
|
||
self.arcs = arcs
|
||
}
|
||
|
||
// MARK: - Computed Properties
|
||
|
||
/// The most recent arc flagged as active.
|
||
var currentArc: RitualArc? {
|
||
(arcs ?? [])
|
||
.filter { $0.isActive }
|
||
.max { $0.startDate < $1.startDate }
|
||
}
|
||
|
||
/// Whether this ritual has an active arc in progress.
|
||
var hasActiveArc: Bool {
|
||
activeArc(on: Date()) != nil
|
||
}
|
||
|
||
/// All arcs sorted by start date (newest first).
|
||
var sortedArcs: [RitualArc] {
|
||
(arcs ?? []).sorted { $0.startDate > $1.startDate }
|
||
}
|
||
|
||
/// The most recent arc (active or completed).
|
||
var latestArc: RitualArc? {
|
||
sortedArcs.first
|
||
}
|
||
|
||
/// Total number of completed arcs.
|
||
var completedArcCount: Int {
|
||
completedArcs(asOf: Date()).count
|
||
}
|
||
|
||
/// The end date of the most recently completed arc, if any.
|
||
var lastCompletedDate: Date? {
|
||
completedArcs(asOf: Date())
|
||
.sorted { $0.endDate > $1.endDate }
|
||
.first?.endDate
|
||
}
|
||
|
||
// MARK: - Convenience Accessors (for current arc)
|
||
|
||
/// Habits from the current arc sorted by sortIndex (empty if no active arc).
|
||
var habits: [ArcHabit] {
|
||
(activeArc(on: Date())?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||
}
|
||
|
||
/// Start date of the current arc.
|
||
var startDate: Date {
|
||
activeArc(on: Date())?.startDate ?? Date()
|
||
}
|
||
|
||
/// Duration of the current arc in days.
|
||
var durationDays: Int {
|
||
activeArc(on: Date())?.durationDays ?? defaultDurationDays
|
||
}
|
||
|
||
/// End date of the current arc.
|
||
var endDate: Date {
|
||
activeArc(on: Date())?.endDate ?? Date()
|
||
}
|
||
|
||
// MARK: - Arc Queries
|
||
|
||
/// Returns the active in-progress arc for a date, if any.
|
||
func activeArc(on date: Date) -> RitualArc? {
|
||
(arcs ?? [])
|
||
.filter { $0.isInProgress(on: date) }
|
||
.max { $0.startDate < $1.startDate }
|
||
}
|
||
|
||
/// Returns arcs that should be treated as completed as of a date.
|
||
func completedArcs(asOf date: Date = Date()) -> [RitualArc] {
|
||
(arcs ?? []).filter { $0.isCompleted(asOf: date) }
|
||
}
|
||
|
||
/// Returns the arc that was active on a specific date, if any.
|
||
func arc(for date: Date) -> RitualArc? {
|
||
(arcs ?? [])
|
||
.filter { $0.contains(date: date) }
|
||
.max { $0.startDate < $1.startDate }
|
||
}
|
||
|
||
/// Returns all arcs that overlap with a date range.
|
||
func arcs(in range: ClosedRange<Date>) -> [RitualArc] {
|
||
(arcs ?? []).filter { arc in
|
||
// Arc overlaps if its range intersects with the query range
|
||
arc.endDate >= range.lowerBound && arc.startDate <= range.upperBound
|
||
}
|
||
}
|
||
}
|