Andromida/Andromida/App/Models/Ritual.swift

222 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}