Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 20:46:59 -06:00
parent b00b2cdafa
commit 30ef5f13ed
7 changed files with 61 additions and 23 deletions

View File

@ -12,6 +12,9 @@ final class ArcHabit {
var goal: String = "" var goal: String = ""
var completedDayIDs: [String] = [] var completedDayIDs: [String] = []
/// Persisted sort order for deterministic ordering across CloudKit sync.
var sortIndex: Int = 0
@Relationship(inverse: \RitualArc.habits) @Relationship(inverse: \RitualArc.habits)
var arc: RitualArc? var arc: RitualArc?
@ -20,12 +23,14 @@ final class ArcHabit {
title: String, title: String,
symbolName: String, symbolName: String,
goal: String = "", goal: String = "",
sortIndex: Int = 0,
completedDayIDs: [String] = [] completedDayIDs: [String] = []
) { ) {
self.id = id self.id = id
self.title = title self.title = title
self.symbolName = symbolName self.symbolName = symbolName
self.goal = goal self.goal = goal
self.sortIndex = sortIndex
self.completedDayIDs = completedDayIDs self.completedDayIDs = completedDayIDs
} }
@ -35,6 +40,7 @@ final class ArcHabit {
title: title, title: title,
symbolName: symbolName, symbolName: symbolName,
goal: goal, goal: goal,
sortIndex: sortIndex,
completedDayIDs: [] completedDayIDs: []
) )
} }

View File

@ -88,6 +88,10 @@ final class Ritual {
var iconName: String = "sparkles" var iconName: String = "sparkles"
var category: String = "" 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 // Arcs - each arc represents a time-bound period with its own habits
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var arcs: [RitualArc]? = [] var arcs: [RitualArc]? = []
@ -101,6 +105,7 @@ final class Ritual {
timeOfDay: TimeOfDay = .anytime, timeOfDay: TimeOfDay = .anytime,
iconName: String = "sparkles", iconName: String = "sparkles",
category: String = "", category: String = "",
sortIndex: Int = 0,
arcs: [RitualArc] = [] arcs: [RitualArc] = []
) { ) {
self.id = id self.id = id
@ -111,6 +116,7 @@ final class Ritual {
self.timeOfDay = timeOfDay self.timeOfDay = timeOfDay
self.iconName = iconName self.iconName = iconName
self.category = category self.category = category
self.sortIndex = sortIndex
self.arcs = arcs self.arcs = arcs
} }
@ -150,9 +156,9 @@ final class Ritual {
// MARK: - Convenience Accessors (for current arc) // MARK: - Convenience Accessors (for current arc)
/// Habits from the current arc (empty if no active arc). /// Habits from the current arc sorted by sortIndex (empty if no active arc).
var habits: [ArcHabit] { var habits: [ArcHabit] {
currentArc?.habits ?? [] (currentArc?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
} }
/// Start date of the current arc. /// Start date of the current arc.

View File

@ -4,9 +4,9 @@ struct RitualSeedService: RitualSeedProviding {
func makeSeedRituals(startDate: Date) -> [Ritual] { func makeSeedRituals(startDate: Date) -> [Ritual] {
// Create morning ritual with arc // Create morning ritual with arc
let morningHabits = [ let morningHabits = [
ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill", sortIndex: 0),
ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk"), ArcHabit(title: String(localized: "Stretch"), symbolName: "figure.walk", sortIndex: 1),
ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles") ArcHabit(title: String(localized: "Mindful minute"), symbolName: "sparkles", sortIndex: 2)
] ]
let morningArc = RitualArc( let morningArc = RitualArc(
startDate: startDate, startDate: startDate,
@ -23,14 +23,15 @@ struct RitualSeedService: RitualSeedProviding {
timeOfDay: .morning, timeOfDay: .morning,
iconName: "sunrise.fill", iconName: "sunrise.fill",
category: String(localized: "Wellness"), category: String(localized: "Wellness"),
sortIndex: 0,
arcs: [morningArc] arcs: [morningArc]
) )
// Create evening ritual with arc // Create evening ritual with arc
let eveningHabits = [ let eveningHabits = [
ArcHabit(title: String(localized: "No screens"), symbolName: "moon.stars.fill"), ArcHabit(title: String(localized: "No screens"), symbolName: "moon.stars.fill", sortIndex: 0),
ArcHabit(title: String(localized: "Read 10 pages"), symbolName: "book.fill"), ArcHabit(title: String(localized: "Read 10 pages"), symbolName: "book.fill", sortIndex: 1),
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
] ]
let eveningStartDate = Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate let eveningStartDate = Calendar.current.date(byAdding: .day, value: -14, to: startDate) ?? startDate
let eveningArc = RitualArc( let eveningArc = RitualArc(
@ -48,6 +49,7 @@ struct RitualSeedService: RitualSeedProviding {
timeOfDay: .evening, timeOfDay: .evening,
iconName: "moon.stars.fill", iconName: "moon.stars.fill",
category: String(localized: "Wellness"), category: String(localized: "Wellness"),
sortIndex: 1,
arcs: [eveningArc] arcs: [eveningArc]
) )

View File

@ -609,9 +609,9 @@ final class RitualStore: RitualStoreProviding {
func createQuickRitual() { func createQuickRitual() {
let defaultDuration = 28 let defaultDuration = 28
let habits = [ let habits = [
ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill"), ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill", sortIndex: 0),
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk"), ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1),
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard") ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
] ]
let arc = RitualArc( let arc = RitualArc(
startDate: Date(), startDate: Date(),
@ -620,11 +620,13 @@ final class RitualStore: RitualStoreProviding {
isActive: true, isActive: true,
habits: habits habits: habits
) )
let nextSortIndex = rituals.count
let ritual = Ritual( let ritual = Ritual(
title: String(localized: "Custom Ritual"), title: String(localized: "Custom Ritual"),
theme: String(localized: "Your next chapter"), theme: String(localized: "Your next chapter"),
defaultDurationDays: defaultDuration, defaultDurationDays: defaultDuration,
notes: String(localized: "A fresh ritual created from your focus today."), notes: String(localized: "A fresh ritual created from your focus today."),
sortIndex: nextSortIndex,
arcs: [arc] arcs: [arc]
) )
modelContext.insert(ritual) modelContext.insert(ritual)
@ -642,13 +644,19 @@ final class RitualStore: RitualStoreProviding {
category: String = "", category: String = "",
habits: [ArcHabit] = [] habits: [ArcHabit] = []
) { ) {
// Assign sortIndex to habits if not already set
let indexedHabits = habits.enumerated().map { index, habit in
habit.sortIndex = index
return habit
}
let arc = RitualArc( let arc = RitualArc(
startDate: Date(), startDate: Date(),
durationDays: durationDays, durationDays: durationDays,
arcNumber: 1, arcNumber: 1,
isActive: true, isActive: true,
habits: habits habits: indexedHabits
) )
let nextSortIndex = rituals.count
let ritual = Ritual( let ritual = Ritual(
title: title, title: title,
theme: theme, theme: theme,
@ -657,6 +665,7 @@ final class RitualStore: RitualStoreProviding {
timeOfDay: timeOfDay, timeOfDay: timeOfDay,
iconName: iconName, iconName: iconName,
category: category, category: category,
sortIndex: nextSortIndex,
arcs: [arc] arcs: [arc]
) )
modelContext.insert(ritual) modelContext.insert(ritual)
@ -665,8 +674,8 @@ final class RitualStore: RitualStoreProviding {
/// Creates a ritual from a preset template /// Creates a ritual from a preset template
func createRitualFromPreset(_ preset: RitualPreset) { func createRitualFromPreset(_ preset: RitualPreset) {
let habits = preset.habits.map { habitPreset in let habits = preset.habits.enumerated().map { index, habitPreset in
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName) ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
} }
createRitual( createRitual(
title: preset.title, title: preset.title,
@ -684,8 +693,8 @@ final class RitualStore: RitualStoreProviding {
/// Used during onboarding to immediately show the created ritual. /// Used during onboarding to immediately show the created ritual.
@discardableResult @discardableResult
func createRitual(from preset: RitualPreset) -> Ritual { func createRitual(from preset: RitualPreset) -> Ritual {
let habits = preset.habits.map { habitPreset in let habits = preset.habits.enumerated().map { index, habitPreset in
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName) ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
} }
let arc = RitualArc( let arc = RitualArc(
startDate: Date(), startDate: Date(),
@ -694,6 +703,7 @@ final class RitualStore: RitualStoreProviding {
isActive: true, isActive: true,
habits: habits habits: habits
) )
let nextSortIndex = rituals.count
let ritual = Ritual( let ritual = Ritual(
title: preset.title, title: preset.title,
theme: preset.theme, theme: preset.theme,
@ -702,6 +712,7 @@ final class RitualStore: RitualStoreProviding {
timeOfDay: preset.timeOfDay, timeOfDay: preset.timeOfDay,
iconName: preset.iconName, iconName: preset.iconName,
category: preset.category, category: preset.category,
sortIndex: nextSortIndex,
arcs: [arc] arcs: [arc]
) )
modelContext.insert(ritual) modelContext.insert(ritual)
@ -739,10 +750,12 @@ final class RitualStore: RitualStoreProviding {
/// Adds a habit to the current arc of a ritual /// Adds a habit to the current arc of a ritual
func addHabit(to ritual: Ritual, title: String, symbolName: String) { func addHabit(to ritual: Ritual, title: String, symbolName: String) {
guard let arc = ritual.currentArc else { return } guard let arc = ritual.currentArc else { return }
let habit = ArcHabit(title: title, symbolName: symbolName) let habits = arc.habits ?? []
var habits = arc.habits ?? [] let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0
habits.append(habit) let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex)
arc.habits = habits var updatedHabits = habits
updatedHabits.append(habit)
arc.habits = updatedHabits
saveContext() saveContext()
} }
@ -785,7 +798,12 @@ final class RitualStore: RitualStoreProviding {
private func updateDerivedData() { private func updateDerivedData() {
currentRituals = rituals currentRituals = rituals
.filter { $0.hasActiveArc } .filter { $0.hasActiveArc }
.sorted { $0.timeOfDay < $1.timeOfDay } .sorted { lhs, rhs in
if lhs.timeOfDay != rhs.timeOfDay {
return lhs.timeOfDay < rhs.timeOfDay
}
return lhs.sortIndex < rhs.sortIndex
}
pastRituals = rituals pastRituals = rituals
.filter { !$0.hasActiveArc } .filter { !$0.hasActiveArc }
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) } .sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }

View File

@ -442,7 +442,9 @@ struct RitualEditSheet: View {
} }
} else { } else {
// Create new ritual // Create new ritual
let newHabits = habits.map { ArcHabit(title: $0.title, symbolName: $0.symbolName) } let newHabits = habits.enumerated().map { index, habit in
ArcHabit(title: habit.title, symbolName: habit.symbolName, sortIndex: index)
}
store.createRitual( store.createRitual(
title: trimmedTitle, title: trimmedTitle,
theme: trimmedTheme, theme: trimmedTheme,

View File

@ -5,17 +5,20 @@ struct TodayHabitRowView: View {
private let title: String private let title: String
private let symbolName: String private let symbolName: String
private let isCompleted: Bool private let isCompleted: Bool
private let horizontalPadding: CGFloat
private let action: () -> Void private let action: () -> Void
init( init(
title: String, title: String,
symbolName: String, symbolName: String,
isCompleted: Bool, isCompleted: Bool,
horizontalPadding: CGFloat = Design.Spacing.large,
action: @escaping () -> Void action: @escaping () -> Void
) { ) {
self.title = title self.title = title
self.symbolName = symbolName self.symbolName = symbolName
self.isCompleted = isCompleted self.isCompleted = isCompleted
self.horizontalPadding = horizontalPadding
self.action = action self.action = action
} }
@ -39,7 +42,7 @@ struct TodayHabitRowView: View {
.foregroundStyle(isCompleted ? AppStatus.success : AppBorder.subtle) .foregroundStyle(isCompleted ? AppStatus.success : AppBorder.subtle)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, horizontalPadding)
.padding(.vertical, Design.Spacing.medium) .padding(.vertical, Design.Spacing.medium)
.background(AppSurface.card) .background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))

View File

@ -75,6 +75,7 @@ struct TodayRitualSectionView: View {
title: habit.title, title: habit.title,
symbolName: habit.symbolName, symbolName: habit.symbolName,
isCompleted: habit.isCompleted, isCompleted: habit.isCompleted,
horizontalPadding: 0,
action: habit.action action: habit.action
) )
} }