updates from codex
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
28c0282068
commit
469f960fec
@ -27,12 +27,15 @@ struct AndromidaApp: App {
|
|||||||
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
|
|
||||||
// Use App Group for shared container between app and widget
|
// Use App Group for shared container between app and widget.
|
||||||
|
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
|
||||||
|
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
||||||
|
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
|
||||||
|
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||||
let configuration = ModelConfiguration(
|
let configuration = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
url: storeURL,
|
||||||
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite"),
|
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||||
cloudKitDatabase: .private(AppIdentifiers.cloudKitContainerIdentifier)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
|||||||
@ -1716,6 +1716,18 @@
|
|||||||
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Next ritual: %@ (%@)" : {
|
||||||
|
"comment" : "A message displayed when there are no rituals scheduled for the current time of day, but there is one scheduled for a different time. The first argument is the name of the time period (e.g. \"morning\"). The second argument is the name of the time period (e.g. \"afternoon\").",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Next ritual: %1$@ (%2$@)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Next ritual: Tomorrow %@ (%@)" : {
|
"Next ritual: Tomorrow %@ (%@)" : {
|
||||||
"comment" : "A one-line hint in the Today empty state indicating the next ritual scheduled for tomorrow. The first argument is the time of day, the second is its time range.",
|
"comment" : "A one-line hint in the Today empty state indicating the next ritual scheduled for tomorrow. The first argument is the time of day, the second is its time range.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@ -172,22 +172,22 @@ final class Ritual {
|
|||||||
|
|
||||||
/// Habits from the current arc sorted by sortIndex (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 ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
(activeArc(on: Date())?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start date of the current arc.
|
/// Start date of the current arc.
|
||||||
var startDate: Date {
|
var startDate: Date {
|
||||||
currentArc?.startDate ?? Date()
|
activeArc(on: Date())?.startDate ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Duration of the current arc in days.
|
/// Duration of the current arc in days.
|
||||||
var durationDays: Int {
|
var durationDays: Int {
|
||||||
currentArc?.durationDays ?? defaultDurationDays
|
activeArc(on: Date())?.durationDays ?? defaultDurationDays
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End date of the current arc.
|
/// End date of the current arc.
|
||||||
var endDate: Date {
|
var endDate: Date {
|
||||||
currentArc?.endDate ?? Date()
|
activeArc(on: Date())?.endDate ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Arc Queries
|
// MARK: - Arc Queries
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Minimal settings surface RitualStore needs for check-in feedback behavior.
|
||||||
|
protocol RitualFeedbackSettingsProviding {
|
||||||
|
var hapticsEnabled: Bool { get }
|
||||||
|
var soundEnabled: Bool { get }
|
||||||
|
}
|
||||||
@ -8,10 +8,15 @@ import WidgetKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class RitualStore: RitualStoreProviding {
|
final class RitualStore: RitualStoreProviding {
|
||||||
|
private static let dataIntegrityMigrationVersion = 1
|
||||||
|
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
|
||||||
|
|
||||||
@ObservationIgnored private let modelContext: ModelContext
|
@ObservationIgnored private let modelContext: ModelContext
|
||||||
@ObservationIgnored private let seedService: RitualSeedProviding
|
@ObservationIgnored private let seedService: RitualSeedProviding
|
||||||
@ObservationIgnored private let settingsStore: SettingsStore
|
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
|
||||||
@ObservationIgnored private let calendar: Calendar
|
@ObservationIgnored private let calendar: Calendar
|
||||||
|
@ObservationIgnored private let nowProvider: () -> Date
|
||||||
|
@ObservationIgnored private let isRunningTests: Bool
|
||||||
@ObservationIgnored private let dayFormatter: DateFormatter
|
@ObservationIgnored private let dayFormatter: DateFormatter
|
||||||
@ObservationIgnored private let displayFormatter: DateFormatter
|
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||||
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
||||||
@ -35,7 +40,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
var ritualNeedingRenewal: Ritual?
|
var ritualNeedingRenewal: Ritual?
|
||||||
|
|
||||||
/// The current time of day, updated periodically. Observable for UI refresh.
|
/// The current time of day, updated periodically. Observable for UI refresh.
|
||||||
private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current()
|
private(set) var currentTimeOfDay: TimeOfDay = .anytime
|
||||||
|
|
||||||
/// Debug override for time of day (nil = use real time)
|
/// Debug override for time of day (nil = use real time)
|
||||||
var debugTimeOfDayOverride: TimeOfDay? {
|
var debugTimeOfDayOverride: TimeOfDay? {
|
||||||
@ -53,13 +58,16 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
init(
|
init(
|
||||||
modelContext: ModelContext,
|
modelContext: ModelContext,
|
||||||
seedService: RitualSeedProviding,
|
seedService: RitualSeedProviding,
|
||||||
settingsStore: SettingsStore,
|
settingsStore: any RitualFeedbackSettingsProviding,
|
||||||
calendar: Calendar = .current
|
calendar: Calendar = .current,
|
||||||
|
now: @escaping () -> Date = Date.init
|
||||||
) {
|
) {
|
||||||
self.modelContext = modelContext
|
self.modelContext = modelContext
|
||||||
self.seedService = seedService
|
self.seedService = seedService
|
||||||
self.settingsStore = settingsStore
|
self.settingsStore = settingsStore
|
||||||
self.calendar = calendar
|
self.calendar = calendar
|
||||||
|
self.nowProvider = now
|
||||||
|
self.isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||||
self.dayFormatter = DateFormatter()
|
self.dayFormatter = DateFormatter()
|
||||||
self.displayFormatter = DateFormatter()
|
self.displayFormatter = DateFormatter()
|
||||||
dayFormatter.calendar = calendar
|
dayFormatter.calendar = calendar
|
||||||
@ -67,10 +75,16 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
displayFormatter.calendar = calendar
|
displayFormatter.calendar = calendar
|
||||||
displayFormatter.dateStyle = .full
|
displayFormatter.dateStyle = .full
|
||||||
displayFormatter.timeStyle = .none
|
displayFormatter.timeStyle = .none
|
||||||
|
self.currentTimeOfDay = TimeOfDay.current(for: now())
|
||||||
|
runDataIntegrityMigrationIfNeeded()
|
||||||
loadRitualsIfNeeded()
|
loadRitualsIfNeeded()
|
||||||
observeRemoteChanges()
|
observeRemoteChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func now() -> Date {
|
||||||
|
nowProvider()
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
if let observer = remoteChangeObserver {
|
if let observer = remoteChangeObserver {
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
@ -99,7 +113,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var todayDisplayString: String {
|
var todayDisplayString: String {
|
||||||
displayFormatter.string(from: Date())
|
displayFormatter.string(from: now())
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeRitualProgress: Double {
|
var activeRitualProgress: Double {
|
||||||
@ -120,7 +134,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Updates the current time of day and returns true if it changed.
|
/// Updates the current time of day and returns true if it changed.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func updateCurrentTimeOfDay() -> Bool {
|
func updateCurrentTimeOfDay() -> Bool {
|
||||||
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current()
|
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
|
||||||
if newTimeOfDay != currentTimeOfDay {
|
if newTimeOfDay != currentTimeOfDay {
|
||||||
currentTimeOfDay = newTimeOfDay
|
currentTimeOfDay = newTimeOfDay
|
||||||
return true
|
return true
|
||||||
@ -130,16 +144,16 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the effective time of day (considering debug override).
|
/// Returns the effective time of day (considering debug override).
|
||||||
func effectiveTimeOfDay() -> TimeOfDay {
|
func effectiveTimeOfDay() -> TimeOfDay {
|
||||||
debugTimeOfDayOverride ?? TimeOfDay.current()
|
debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refreshes rituals if the last refresh was beyond the minimum interval.
|
/// Refreshes rituals if the last refresh was beyond the minimum interval.
|
||||||
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
|
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
|
||||||
let now = Date()
|
let currentDate = now()
|
||||||
if let lastRefreshDate, now.timeIntervalSince(lastRefreshDate) < minimumInterval {
|
if let lastRefreshDate, currentDate.timeIntervalSince(lastRefreshDate) < minimumInterval {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastRefreshDate = now
|
lastRefreshDate = currentDate
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,15 +169,15 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
|
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
|
||||||
let dayID = dayIdentifier(for: Date())
|
let dayID = dayIdentifier(for: now())
|
||||||
return habit.completedDayIDs.contains(dayID)
|
return habit.completedDayIDs.contains(dayID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleHabitCompletion(_ habit: ArcHabit) {
|
func toggleHabitCompletion(_ habit: ArcHabit) {
|
||||||
toggleHabitCompletion(habit, date: Date())
|
toggleHabitCompletion(habit, date: now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleHabitCompletion(_ habit: ArcHabit, date: Date = Date()) {
|
func toggleHabitCompletion(_ habit: ArcHabit, date: Date) {
|
||||||
let dayID = dayIdentifier(for: date)
|
let dayID = dayIdentifier(for: date)
|
||||||
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
||||||
|
|
||||||
@ -183,8 +197,9 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ritualDayIndex(for ritual: Ritual) -> Int {
|
func ritualDayIndex(for ritual: Ritual) -> Int {
|
||||||
guard let arc = ritual.activeArc(on: Date()) else { return 0 }
|
let currentDate = now()
|
||||||
return arc.dayIndex(for: Date())
|
guard let arc = ritual.activeArc(on: currentDate) else { return 0 }
|
||||||
|
return arc.dayIndex(for: currentDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ritualDayLabel(for ritual: Ritual) -> String {
|
func ritualDayLabel(for ritual: Ritual) -> String {
|
||||||
@ -213,7 +228,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||||
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
|
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
|
||||||
func ritualsForToday() -> [Ritual] {
|
func ritualsForToday() -> [Ritual] {
|
||||||
let today = Date()
|
let today = now()
|
||||||
let timeOfDay = effectiveTimeOfDay()
|
let timeOfDay = effectiveTimeOfDay()
|
||||||
|
|
||||||
return currentRituals.filter { ritual in
|
return currentRituals.filter { ritual in
|
||||||
@ -256,7 +271,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Checks if a ritual's current arc has completed (past end date).
|
/// Checks if a ritual's current arc has completed (past end date).
|
||||||
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
||||||
guard let arc = ritual.currentArc else { return false }
|
guard let arc = ritual.currentArc else { return false }
|
||||||
return arc.isCompleted(asOf: Date(), calendar: calendar)
|
return arc.isCompleted(asOf: now(), calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks for rituals that need renewal and triggers the prompt.
|
/// Checks for rituals that need renewal and triggers the prompt.
|
||||||
@ -292,7 +307,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newArc = RitualArc(
|
let newArc = RitualArc(
|
||||||
startDate: Date(),
|
startDate: now(),
|
||||||
durationDays: duration,
|
durationDays: duration,
|
||||||
arcNumber: newArcNumber,
|
arcNumber: newArcNumber,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@ -342,7 +357,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||||
func currentStreak() -> Int {
|
func currentStreak() -> Int {
|
||||||
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar)
|
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar, currentDate: now())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the longest streak of consecutive perfect days
|
/// Calculates the longest streak of consecutive perfect days
|
||||||
@ -378,7 +393,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns completion data for the last 7 days for a trend chart
|
/// Returns completion data for the last 7 days for a trend chart
|
||||||
func weeklyTrendData() -> [TrendDataPoint] {
|
func weeklyTrendData() -> [TrendDataPoint] {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: now())
|
||||||
let shortWeekdayFormatter = DateFormatter()
|
let shortWeekdayFormatter = DateFormatter()
|
||||||
shortWeekdayFormatter.calendar = calendar
|
shortWeekdayFormatter.calendar = calendar
|
||||||
shortWeekdayFormatter.dateFormat = "EEE"
|
shortWeekdayFormatter.dateFormat = "EEE"
|
||||||
@ -476,12 +491,13 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func computeInsightCards() -> [InsightCard] {
|
private func computeInsightCards() -> [InsightCard] {
|
||||||
|
let currentDate = now()
|
||||||
let inProgressRituals = currentRituals.filter { ritual in
|
let inProgressRituals = currentRituals.filter { ritual in
|
||||||
ritual.activeArc(on: Date()) != nil
|
ritual.activeArc(on: currentDate) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only count habits from active arcs for today's stats
|
// Only count habits from active arcs for today's stats
|
||||||
let activeHabitsToday = habitsInProgress(on: Date())
|
let activeHabitsToday = habitsInProgress(on: currentDate)
|
||||||
let totalHabits = activeHabitsToday.count
|
let totalHabits = activeHabitsToday.count
|
||||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||||
@ -528,11 +544,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let bestRitualInfo: (title: String, rate: Int)? = {
|
let bestRitualInfo: (title: String, rate: Int)? = {
|
||||||
var best: (title: String, rate: Int)?
|
var best: (title: String, rate: Int)?
|
||||||
for ritual in inProgressRituals {
|
for ritual in inProgressRituals {
|
||||||
guard let arc = ritual.activeArc(on: Date()) else { continue }
|
guard let arc = ritual.activeArc(on: currentDate) else { continue }
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
guard !habits.isEmpty else { continue }
|
guard !habits.isEmpty else { continue }
|
||||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
let possibleCheckIns = habits.count * arc.dayIndex(for: Date())
|
let possibleCheckIns = habits.count * arc.dayIndex(for: currentDate)
|
||||||
guard possibleCheckIns > 0 else { continue }
|
guard possibleCheckIns > 0 else { continue }
|
||||||
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
||||||
if best.map({ rate > $0.rate }) ?? true {
|
if best.map({ rate > $0.rate }) ?? true {
|
||||||
@ -676,26 +692,69 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1),
|
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1),
|
||||||
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
|
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
|
||||||
]
|
]
|
||||||
let arc = RitualArc(
|
_ = createRitualWithInitialArc(
|
||||||
startDate: Date(),
|
|
||||||
durationDays: defaultDuration,
|
|
||||||
arcNumber: 1,
|
|
||||||
isActive: true,
|
|
||||||
habits: habits
|
|
||||||
)
|
|
||||||
let nextSortIndex = rituals.count
|
|
||||||
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,
|
timeOfDay: .anytime,
|
||||||
arcs: [arc]
|
iconName: "sparkles",
|
||||||
|
category: "",
|
||||||
|
sortIndex: rituals.count,
|
||||||
|
durationDays: defaultDuration,
|
||||||
|
habits: habits
|
||||||
)
|
)
|
||||||
modelContext.insert(ritual)
|
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func createRitualWithInitialArc(
|
||||||
|
title: String,
|
||||||
|
theme: String,
|
||||||
|
defaultDurationDays: Int,
|
||||||
|
notes: String,
|
||||||
|
timeOfDay: TimeOfDay,
|
||||||
|
iconName: String,
|
||||||
|
category: String,
|
||||||
|
sortIndex: Int,
|
||||||
|
durationDays: Int,
|
||||||
|
habits: [ArcHabit]
|
||||||
|
) -> Ritual {
|
||||||
|
let ritual = Ritual(
|
||||||
|
title: title,
|
||||||
|
theme: theme,
|
||||||
|
defaultDurationDays: defaultDurationDays,
|
||||||
|
notes: notes,
|
||||||
|
timeOfDay: timeOfDay,
|
||||||
|
iconName: iconName,
|
||||||
|
category: category,
|
||||||
|
sortIndex: sortIndex,
|
||||||
|
arcs: []
|
||||||
|
)
|
||||||
|
|
||||||
|
modelContext.insert(ritual)
|
||||||
|
|
||||||
|
let indexedHabits = habits.enumerated().map { index, habit in
|
||||||
|
habit.sortIndex = index
|
||||||
|
return habit
|
||||||
|
}
|
||||||
|
for habit in indexedHabits {
|
||||||
|
modelContext.insert(habit)
|
||||||
|
}
|
||||||
|
|
||||||
|
let arc = RitualArc(
|
||||||
|
startDate: now(),
|
||||||
|
durationDays: durationDays,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: []
|
||||||
|
)
|
||||||
|
modelContext.insert(arc)
|
||||||
|
arc.habits = indexedHabits
|
||||||
|
ritual.arcs = [arc]
|
||||||
|
|
||||||
|
return ritual
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a new ritual with the given properties
|
/// Creates a new ritual with the given properties
|
||||||
func createRitual(
|
func createRitual(
|
||||||
title: String,
|
title: String,
|
||||||
@ -707,20 +766,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
category: String = "",
|
category: String = "",
|
||||||
habits: [ArcHabit] = []
|
habits: [ArcHabit] = []
|
||||||
) {
|
) {
|
||||||
// Assign sortIndex to habits if not already set
|
_ = createRitualWithInitialArc(
|
||||||
let indexedHabits = habits.enumerated().map { index, habit in
|
|
||||||
habit.sortIndex = index
|
|
||||||
return habit
|
|
||||||
}
|
|
||||||
let arc = RitualArc(
|
|
||||||
startDate: Date(),
|
|
||||||
durationDays: durationDays,
|
|
||||||
arcNumber: 1,
|
|
||||||
isActive: true,
|
|
||||||
habits: indexedHabits
|
|
||||||
)
|
|
||||||
let nextSortIndex = rituals.count
|
|
||||||
let ritual = Ritual(
|
|
||||||
title: title,
|
title: title,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
defaultDurationDays: durationDays,
|
defaultDurationDays: durationDays,
|
||||||
@ -728,10 +774,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
timeOfDay: timeOfDay,
|
timeOfDay: timeOfDay,
|
||||||
iconName: iconName,
|
iconName: iconName,
|
||||||
category: category,
|
category: category,
|
||||||
sortIndex: nextSortIndex,
|
sortIndex: rituals.count,
|
||||||
arcs: [arc]
|
durationDays: durationDays,
|
||||||
|
habits: habits
|
||||||
)
|
)
|
||||||
modelContext.insert(ritual)
|
|
||||||
saveContext()
|
saveContext()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -759,15 +805,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let habits = preset.habits.enumerated().map { index, habitPreset in
|
let habits = preset.habits.enumerated().map { index, habitPreset in
|
||||||
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
|
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
|
||||||
}
|
}
|
||||||
let arc = RitualArc(
|
|
||||||
startDate: Date(),
|
let ritual = createRitualWithInitialArc(
|
||||||
durationDays: preset.durationDays,
|
|
||||||
arcNumber: 1,
|
|
||||||
isActive: true,
|
|
||||||
habits: habits
|
|
||||||
)
|
|
||||||
let nextSortIndex = rituals.count
|
|
||||||
let ritual = Ritual(
|
|
||||||
title: preset.title,
|
title: preset.title,
|
||||||
theme: preset.theme,
|
theme: preset.theme,
|
||||||
defaultDurationDays: preset.durationDays,
|
defaultDurationDays: preset.durationDays,
|
||||||
@ -775,10 +814,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
timeOfDay: preset.timeOfDay,
|
timeOfDay: preset.timeOfDay,
|
||||||
iconName: preset.iconName,
|
iconName: preset.iconName,
|
||||||
category: preset.category,
|
category: preset.category,
|
||||||
sortIndex: nextSortIndex,
|
sortIndex: rituals.count,
|
||||||
arcs: [arc]
|
durationDays: preset.durationDays,
|
||||||
|
habits: habits
|
||||||
)
|
)
|
||||||
modelContext.insert(ritual)
|
|
||||||
saveContext()
|
saveContext()
|
||||||
return ritual
|
return ritual
|
||||||
}
|
}
|
||||||
@ -803,7 +842,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
ritual.category = category
|
ritual.category = category
|
||||||
|
|
||||||
// Also update the current arc's end date if duration changed
|
// Also update the current arc's end date if duration changed
|
||||||
if let currentArc = ritual.currentArc, currentArc.durationDays != durationDays {
|
if let currentArc = ritual.activeArc(on: now()), currentArc.durationDays != durationDays {
|
||||||
let newEndDate = calendar.date(byAdding: .day, value: durationDays - 1, to: currentArc.startDate) ?? currentArc.endDate
|
let newEndDate = calendar.date(byAdding: .day, value: durationDays - 1, to: currentArc.startDate) ?? currentArc.endDate
|
||||||
currentArc.endDate = newEndDate
|
currentArc.endDate = newEndDate
|
||||||
}
|
}
|
||||||
@ -819,7 +858,7 @@ 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.activeArc(on: now()) else { return }
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0
|
let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0
|
||||||
let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex)
|
let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex)
|
||||||
@ -831,7 +870,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Removes a habit from the current arc of a ritual
|
/// Removes a habit from the current arc of a ritual
|
||||||
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
|
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
|
||||||
guard let arc = ritual.currentArc else { return }
|
guard let arc = ritual.activeArc(on: now()) else { return }
|
||||||
var habits = arc.habits ?? []
|
var habits = arc.habits ?? []
|
||||||
habits.removeAll { $0.id == habit.id }
|
habits.removeAll { $0.id == habit.id }
|
||||||
arc.habits = habits
|
arc.habits = habits
|
||||||
@ -845,6 +884,108 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// Users start with empty state and create their own rituals
|
// Users start with empty state and create their own rituals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs a one-time migration to repair arc integrity and normalize sort indexes.
|
||||||
|
/// - Parameter force: Set true to run regardless of stored migration version (used in tests).
|
||||||
|
func runDataIntegrityMigrationIfNeeded(force: Bool = false) {
|
||||||
|
let previousVersion = UserDefaults.standard.integer(forKey: Self.dataIntegrityMigrationVersionKey)
|
||||||
|
guard force || previousVersion < Self.dataIntegrityMigrationVersion else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetchedRituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||||
|
let didChange = applyDataIntegrityMigration(to: fetchedRituals)
|
||||||
|
|
||||||
|
if didChange {
|
||||||
|
try modelContext.save()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDefaults.standard.set(Self.dataIntegrityMigrationVersion, forKey: Self.dataIntegrityMigrationVersionKey)
|
||||||
|
reloadRituals()
|
||||||
|
} catch {
|
||||||
|
lastErrorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDataIntegrityMigration(to rituals: [Ritual]) -> Bool {
|
||||||
|
let today = calendar.startOfDay(for: now())
|
||||||
|
var didChange = false
|
||||||
|
|
||||||
|
// Normalize ritual ordering indexes in the same order the app presents current rituals.
|
||||||
|
let sortedRituals = rituals.sorted { lhs, rhs in
|
||||||
|
if lhs.timeOfDay != rhs.timeOfDay {
|
||||||
|
return lhs.timeOfDay < rhs.timeOfDay
|
||||||
|
}
|
||||||
|
if lhs.sortIndex != rhs.sortIndex {
|
||||||
|
return lhs.sortIndex < rhs.sortIndex
|
||||||
|
}
|
||||||
|
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ritualIndex, ritual) in sortedRituals.enumerated() {
|
||||||
|
if ritual.sortIndex != ritualIndex {
|
||||||
|
ritual.sortIndex = ritualIndex
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let arcs = ritual.arcs ?? []
|
||||||
|
var inProgressActiveArcs: [RitualArc] = []
|
||||||
|
|
||||||
|
for arc in arcs {
|
||||||
|
let normalizedStart = calendar.startOfDay(for: arc.startDate)
|
||||||
|
if arc.startDate != normalizedStart {
|
||||||
|
arc.startDate = normalizedStart
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedEnd = calendar.startOfDay(for: arc.endDate)
|
||||||
|
if arc.endDate != normalizedEnd {
|
||||||
|
arc.endDate = normalizedEnd
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if arc.endDate < arc.startDate {
|
||||||
|
arc.endDate = arc.startDate
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if arc.isInProgress(on: today, calendar: calendar) {
|
||||||
|
inProgressActiveArcs.append(arc)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedHabits = (arc.habits ?? []).sorted { lhs, rhs in
|
||||||
|
if lhs.sortIndex != rhs.sortIndex {
|
||||||
|
return lhs.sortIndex < rhs.sortIndex
|
||||||
|
}
|
||||||
|
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
for (habitIndex, habit) in sortedHabits.enumerated() {
|
||||||
|
if habit.sortIndex != habitIndex {
|
||||||
|
habit.sortIndex = habitIndex
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure only one active in-progress arc per ritual (keep the newest by start date).
|
||||||
|
if inProgressActiveArcs.count > 1 {
|
||||||
|
let sortedInProgress = inProgressActiveArcs.sorted { lhs, rhs in
|
||||||
|
if lhs.startDate != rhs.startDate {
|
||||||
|
return lhs.startDate > rhs.startDate
|
||||||
|
}
|
||||||
|
return lhs.arcNumber > rhs.arcNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
for arc in sortedInProgress.dropFirst() where arc.isActive {
|
||||||
|
arc.isActive = false
|
||||||
|
didChange = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return didChange
|
||||||
|
}
|
||||||
|
|
||||||
private func reloadRituals() {
|
private func reloadRituals() {
|
||||||
do {
|
do {
|
||||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||||
@ -871,8 +1012,9 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateDerivedData() {
|
private func updateDerivedData() {
|
||||||
|
let currentDate = now()
|
||||||
currentRituals = rituals
|
currentRituals = rituals
|
||||||
.filter { $0.hasActiveArc }
|
.filter { $0.activeArc(on: currentDate) != nil }
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
if lhs.timeOfDay != rhs.timeOfDay {
|
if lhs.timeOfDay != rhs.timeOfDay {
|
||||||
return lhs.timeOfDay < rhs.timeOfDay
|
return lhs.timeOfDay < rhs.timeOfDay
|
||||||
@ -880,7 +1022,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return lhs.sortIndex < rhs.sortIndex
|
return lhs.sortIndex < rhs.sortIndex
|
||||||
}
|
}
|
||||||
pastRituals = rituals
|
pastRituals = rituals
|
||||||
.filter { !$0.hasActiveArc }
|
.filter { $0.activeArc(on: currentDate) == nil }
|
||||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -935,6 +1077,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleReminderUpdate() {
|
private func scheduleReminderUpdate() {
|
||||||
|
// Avoid touching live SwiftData model graphs from async reminder tasks during XCTest runs.
|
||||||
|
guard !isRunningTests else { return }
|
||||||
pendingReminderTask?.cancel()
|
pendingReminderTask?.cancel()
|
||||||
let ritualsSnapshot = rituals
|
let ritualsSnapshot = rituals
|
||||||
pendingReminderTask = Task {
|
pendingReminderTask = Task {
|
||||||
@ -1069,8 +1213,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the number of days remaining in the ritual's current arc.
|
/// Returns the number of days remaining in the ritual's current arc.
|
||||||
func daysRemaining(for ritual: Ritual) -> Int {
|
func daysRemaining(for ritual: Ritual) -> Int {
|
||||||
guard let arc = ritual.currentArc else { return 0 }
|
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: now())
|
||||||
let endDate = calendar.startOfDay(for: arc.endDate)
|
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||||
|
|
||||||
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
||||||
@ -1079,12 +1223,12 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the current streak for a specific ritual's current arc.
|
/// Returns the current streak for a specific ritual's current arc.
|
||||||
func streakForRitual(_ ritual: Ritual) -> Int {
|
func streakForRitual(_ ritual: Ritual) -> Int {
|
||||||
guard let arc = ritual.currentArc else { return 0 }
|
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
guard !habits.isEmpty else { return 0 }
|
guard !habits.isEmpty else { return 0 }
|
||||||
|
|
||||||
var streak = 0
|
var streak = 0
|
||||||
var checkDate = calendar.startOfDay(for: Date())
|
var checkDate = calendar.startOfDay(for: now())
|
||||||
|
|
||||||
while arc.contains(date: checkDate) {
|
while arc.contains(date: checkDate) {
|
||||||
let dayID = dayIdentifier(for: checkDate)
|
let dayID = dayIdentifier(for: checkDate)
|
||||||
@ -1105,10 +1249,11 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Returns nil if there's no previous arc to compare against.
|
/// Returns nil if there's no previous arc to compare against.
|
||||||
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
||||||
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
||||||
guard let currentArc = ritual.activeArc(on: Date()) else { return nil }
|
let currentDate = now()
|
||||||
|
guard let currentArc = ritual.activeArc(on: currentDate) else { return nil }
|
||||||
|
|
||||||
// Find the previous arc (most recent completed arc)
|
// Find the previous arc (most recent completed arc)
|
||||||
let completedArcs = ritual.completedArcs(asOf: Date())
|
let completedArcs = ritual.completedArcs(asOf: currentDate)
|
||||||
.sorted { $0.arcNumber > $1.arcNumber }
|
.sorted { $0.arcNumber > $1.arcNumber }
|
||||||
|
|
||||||
guard let previousArc = completedArcs.first else { return nil }
|
guard let previousArc = completedArcs.first else { return nil }
|
||||||
@ -1266,7 +1411,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the week-over-week change in completion rate.
|
/// Returns the week-over-week change in completion rate.
|
||||||
func weekOverWeekChange() -> Double {
|
func weekOverWeekChange() -> Double {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: now())
|
||||||
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -1298,7 +1443,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the insight context for tips generation.
|
/// Returns the insight context for tips generation.
|
||||||
func insightContext() -> InsightContext {
|
func insightContext() -> InsightContext {
|
||||||
let activeHabitsToday = habitsInProgress(on: Date())
|
let activeHabitsToday = habitsInProgress(on: now())
|
||||||
let totalHabits = activeHabitsToday.count
|
let totalHabits = activeHabitsToday.count
|
||||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
||||||
@ -1323,7 +1468,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Preloads 6 months of random completion data for testing the history view.
|
/// Preloads 6 months of random completion data for testing the history view.
|
||||||
func preloadDemoData() {
|
func preloadDemoData() {
|
||||||
let today = calendar.startOfDay(for: Date())
|
let today = calendar.startOfDay(for: now())
|
||||||
|
|
||||||
// Go back 6 months
|
// Go back 6 months
|
||||||
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
||||||
@ -1406,13 +1551,13 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
func simulateArcCompletion() {
|
func simulateArcCompletion() {
|
||||||
// Find the first ritual with an active arc
|
// Find the first ritual with an active arc
|
||||||
guard let ritual = currentRituals.first,
|
guard let ritual = currentRituals.first,
|
||||||
let arc = ritual.currentArc else {
|
let arc = ritual.activeArc(on: now()) else {
|
||||||
print("No active arcs to complete")
|
print("No active arcs to complete")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the end date to yesterday so the arc appears completed
|
// Set the end date to yesterday so the arc appears completed
|
||||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())) ?? Date()
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now())) ?? now()
|
||||||
arc.endDate = yesterday
|
arc.endDate = yesterday
|
||||||
|
|
||||||
// Also backdate the start date so the arc looks like it ran for a reasonable period
|
// Also backdate the start date so the arc looks like it ran for a reasonable period
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Bedrock
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsStore: CloudSyncable, ThemeProviding {
|
final class SettingsStore: CloudSyncable, ThemeProviding, RitualFeedbackSettingsProviding {
|
||||||
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
||||||
@ObservationIgnored private var cloudChangeObserver: NSObjectProtocol?
|
@ObservationIgnored private var cloudChangeObserver: NSObjectProtocol?
|
||||||
@ObservationIgnored private var isApplyingCloudUpdate = false
|
@ObservationIgnored private var isApplyingCloudUpdate = false
|
||||||
|
|||||||
@ -16,11 +16,12 @@ enum RitualAnalytics {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - rituals: The list of rituals to analyze.
|
/// - rituals: The list of rituals to analyze.
|
||||||
/// - calendar: The calendar to use for date calculations.
|
/// - calendar: The calendar to use for date calculations.
|
||||||
|
/// - currentDate: The date used as "today" for streak evaluation.
|
||||||
/// - Returns: The current streak count.
|
/// - Returns: The current streak count.
|
||||||
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int {
|
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current, currentDate: Date = Date()) -> Int {
|
||||||
// Count backwards from today using perfect days (100% completion).
|
// Count backwards from today using perfect days (100% completion).
|
||||||
var streak = 0
|
var streak = 0
|
||||||
var checkDate = calendar.startOfDay(for: Date())
|
var checkDate = calendar.startOfDay(for: currentDate)
|
||||||
|
|
||||||
// If today isn't perfect, start from yesterday.
|
// If today isn't perfect, start from yesterday.
|
||||||
if !isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {
|
if !isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {
|
||||||
|
|||||||
@ -31,11 +31,20 @@ struct RitualStoreTests {
|
|||||||
let store = makeStore()
|
let store = makeStore()
|
||||||
store.createQuickRitual()
|
store.createQuickRitual()
|
||||||
|
|
||||||
guard let habit = store.activeRitual?.habits.first else {
|
guard let ritual = store.activeRitual,
|
||||||
|
let habit = ritual.habits.first,
|
||||||
|
let arc = ritual.currentArc else {
|
||||||
throw TestError.missingHabit
|
throw TestError.missingHabit
|
||||||
}
|
}
|
||||||
|
|
||||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
|
||||||
|
arc.startDate = Calendar.current.startOfDay(for: yesterday)
|
||||||
|
arc.endDate = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: arc.durationDays - 1,
|
||||||
|
to: arc.startDate
|
||||||
|
) ?? arc.endDate
|
||||||
|
|
||||||
store.toggleHabitCompletion(habit, date: yesterday)
|
store.toggleHabitCompletion(habit, date: yesterday)
|
||||||
|
|
||||||
let completions = store.habitCompletions(for: yesterday)
|
let completions = store.habitCompletions(for: yesterday)
|
||||||
@ -74,7 +83,8 @@ struct RitualStoreTests {
|
|||||||
let store = makeStore()
|
let store = makeStore()
|
||||||
store.createQuickRitual()
|
store.createQuickRitual()
|
||||||
|
|
||||||
guard let ritual = store.activeRitual else {
|
guard let ritual = store.activeRitual,
|
||||||
|
let arc = ritual.currentArc else {
|
||||||
throw TestError.missingRitual
|
throw TestError.missingRitual
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +96,14 @@ struct RitualStoreTests {
|
|||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let today = Date()
|
let today = Date()
|
||||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||||
|
let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today) ?? today
|
||||||
|
|
||||||
|
arc.startDate = calendar.startOfDay(for: twoDaysAgo)
|
||||||
|
arc.endDate = calendar.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: arc.durationDays - 1,
|
||||||
|
to: arc.startDate
|
||||||
|
) ?? arc.endDate
|
||||||
|
|
||||||
// Yesterday is perfect: complete all habits.
|
// Yesterday is perfect: complete all habits.
|
||||||
for habit in habits {
|
for habit in habits {
|
||||||
@ -186,20 +204,258 @@ struct RitualStoreTests {
|
|||||||
#expect(context?.ritual.id == eveningRitual.id)
|
#expect(context?.ritual.id == eveningRitual.id)
|
||||||
#expect(context?.isTomorrow == true)
|
#expect(context?.isTomorrow == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func ritualConvenienceAccessorsPreferInProgressArcOverFutureActiveArc() throws {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||||
|
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today
|
||||||
|
let nextWeek = calendar.date(byAdding: .day, value: 7, to: today) ?? today
|
||||||
|
let futureEnd = calendar.date(byAdding: .day, value: 14, to: today) ?? today
|
||||||
|
|
||||||
|
let todayHabit = ArcHabit(title: "Today Habit", symbolName: "sun.max.fill", sortIndex: 0)
|
||||||
|
let futureHabit = ArcHabit(title: "Future Habit", symbolName: "moon.fill", sortIndex: 0)
|
||||||
|
|
||||||
|
let todayArc = RitualArc(startDate: yesterday, endDate: tomorrow, arcNumber: 1, isActive: true, habits: [todayHabit])
|
||||||
|
let futureArc = RitualArc(startDate: nextWeek, endDate: futureEnd, arcNumber: 2, isActive: true, habits: [futureHabit])
|
||||||
|
|
||||||
|
let ritual = Ritual(title: "Arc Accessors", theme: "Test", defaultDurationDays: 28, arcs: [todayArc, futureArc])
|
||||||
|
|
||||||
|
#expect(ritual.currentArc?.arcNumber == 2) // Newest active arc
|
||||||
|
#expect(ritual.habits.first?.title == "Today Habit")
|
||||||
|
#expect(Calendar.current.isDate(ritual.startDate, inSameDayAs: yesterday))
|
||||||
|
#expect(Calendar.current.isDate(ritual.endDate, inSameDayAs: tomorrow))
|
||||||
|
#expect(ritual.durationDays == todayArc.durationDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func addHabitTargetsInProgressArcWhenFutureArcIsAlsoActive() throws {
|
||||||
|
let store = makeStore()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
|
store.createRitual(
|
||||||
|
title: "Add Habit Routing",
|
||||||
|
theme: "Test",
|
||||||
|
habits: [ArcHabit(title: "Now", symbolName: "clock", sortIndex: 0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let ritual = store.rituals.first,
|
||||||
|
let inProgressArc = ritual.activeArc(on: today) else {
|
||||||
|
throw TestError.missingRitual
|
||||||
|
}
|
||||||
|
|
||||||
|
let futureArc = RitualArc(
|
||||||
|
startDate: calendar.date(byAdding: .day, value: 5, to: today) ?? today,
|
||||||
|
endDate: calendar.date(byAdding: .day, value: 12, to: today) ?? today,
|
||||||
|
arcNumber: 2,
|
||||||
|
isActive: true,
|
||||||
|
habits: [ArcHabit(title: "Future", symbolName: "calendar", sortIndex: 0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
var arcs = ritual.arcs ?? []
|
||||||
|
arcs.append(futureArc)
|
||||||
|
ritual.arcs = arcs
|
||||||
|
|
||||||
|
let inProgressBefore = inProgressArc.habits?.count ?? 0
|
||||||
|
let futureBefore = futureArc.habits?.count ?? 0
|
||||||
|
|
||||||
|
store.addHabit(to: ritual, title: "Added Today", symbolName: "plus.circle")
|
||||||
|
|
||||||
|
#expect((inProgressArc.habits?.count ?? 0) == inProgressBefore + 1)
|
||||||
|
#expect((futureArc.habits?.count ?? 0) == futureBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func ritualStoreUsesInjectedCurrentDateForDayIndex() throws {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let fixedNow = calendar.date(from: DateComponents(year: 2026, month: 2, day: 8, hour: 10)) ?? Date()
|
||||||
|
let startDate = calendar.date(byAdding: .day, value: -3, to: fixedNow) ?? fixedNow
|
||||||
|
let endDate = calendar.date(byAdding: .day, value: 10, to: fixedNow) ?? fixedNow
|
||||||
|
|
||||||
|
let store = makeStore(now: { fixedNow })
|
||||||
|
store.createRitual(
|
||||||
|
title: "Injected Time",
|
||||||
|
theme: "Test",
|
||||||
|
habits: [ArcHabit(title: "Habit", symbolName: "clock", sortIndex: 0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let ritual = store.rituals.first,
|
||||||
|
let arc = ritual.currentArc else {
|
||||||
|
throw TestError.missingRitual
|
||||||
|
}
|
||||||
|
|
||||||
|
arc.startDate = calendar.startOfDay(for: startDate)
|
||||||
|
arc.endDate = calendar.startOfDay(for: endDate)
|
||||||
|
|
||||||
|
let expectedDay = arc.dayIndex(for: fixedNow)
|
||||||
|
#expect(store.ritualDayIndex(for: ritual) == expectedDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func currentRitualClassificationUsesInjectedCurrentDate() throws {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let fixedNow = calendar.date(from: DateComponents(year: 2030, month: 6, day: 15, hour: 10)) ?? Date()
|
||||||
|
|
||||||
|
let store = makeStore(now: { fixedNow })
|
||||||
|
store.createRitual(
|
||||||
|
title: "Future Relative To System Clock",
|
||||||
|
theme: "Test",
|
||||||
|
habits: [ArcHabit(title: "Habit", symbolName: "clock", sortIndex: 0)]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.currentRituals.count == 1)
|
||||||
|
#expect(store.pastRituals.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func currentStreakCalculationRespectsProvidedCurrentDate() throws {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let fixedNow = calendar.date(from: DateComponents(year: 2026, month: 2, day: 8, hour: 9)) ?? Date()
|
||||||
|
let dayBefore = calendar.date(byAdding: .day, value: -1, to: fixedNow) ?? fixedNow
|
||||||
|
let twoDaysBefore = calendar.date(byAdding: .day, value: -2, to: fixedNow) ?? fixedNow
|
||||||
|
|
||||||
|
let dayBeforeID = RitualAnalytics.dayIdentifier(for: dayBefore, calendar: calendar)
|
||||||
|
let twoDaysBeforeID = RitualAnalytics.dayIdentifier(for: twoDaysBefore, calendar: calendar)
|
||||||
|
|
||||||
|
let habitOne = ArcHabit(title: "One", symbolName: "1.circle", completedDayIDs: [dayBeforeID, twoDaysBeforeID])
|
||||||
|
let habitTwo = ArcHabit(title: "Two", symbolName: "2.circle", completedDayIDs: [dayBeforeID, twoDaysBeforeID])
|
||||||
|
|
||||||
|
let arc = RitualArc(
|
||||||
|
startDate: calendar.date(byAdding: .day, value: -10, to: fixedNow) ?? fixedNow,
|
||||||
|
endDate: calendar.date(byAdding: .day, value: 10, to: fixedNow) ?? fixedNow,
|
||||||
|
arcNumber: 1,
|
||||||
|
isActive: true,
|
||||||
|
habits: [habitOne, habitTwo]
|
||||||
|
)
|
||||||
|
let ritual = Ritual(title: "Streak", theme: "Test", arcs: [arc])
|
||||||
|
|
||||||
|
let streak = RitualAnalytics.calculateCurrentStreak(
|
||||||
|
rituals: [ritual],
|
||||||
|
calendar: calendar,
|
||||||
|
currentDate: fixedNow
|
||||||
|
)
|
||||||
|
#expect(streak == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func dataIntegrityMigrationRepairsArcsAndSortIndexes() throws {
|
||||||
|
let store = makeStore()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
|
let habits = [
|
||||||
|
ArcHabit(title: "Habit A", symbolName: "a.circle", sortIndex: 5),
|
||||||
|
ArcHabit(title: "Habit B", symbolName: "b.circle", sortIndex: 2)
|
||||||
|
]
|
||||||
|
store.createRitual(
|
||||||
|
title: "Migration Ritual",
|
||||||
|
theme: "Test",
|
||||||
|
durationDays: 28,
|
||||||
|
habits: habits
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let ritual = store.rituals.first,
|
||||||
|
let firstArc = ritual.currentArc else {
|
||||||
|
throw TestError.missingRitual
|
||||||
|
}
|
||||||
|
|
||||||
|
ritual.sortIndex = 99
|
||||||
|
|
||||||
|
firstArc.startDate = calendar.date(byAdding: .day, value: -2, to: today) ?? today
|
||||||
|
firstArc.endDate = calendar.date(byAdding: .day, value: 5, to: today) ?? today
|
||||||
|
firstArc.habits?[0].sortIndex = 10
|
||||||
|
firstArc.habits?[1].sortIndex = 7
|
||||||
|
|
||||||
|
let secondArc = RitualArc(
|
||||||
|
startDate: calendar.date(byAdding: .day, value: -1, to: today) ?? today,
|
||||||
|
endDate: calendar.date(byAdding: .day, value: 7, to: today) ?? today,
|
||||||
|
arcNumber: 2,
|
||||||
|
isActive: true,
|
||||||
|
habits: [
|
||||||
|
ArcHabit(title: "Habit C", symbolName: "c.circle", sortIndex: 8),
|
||||||
|
ArcHabit(title: "Habit D", symbolName: "d.circle", sortIndex: 4)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
let invalidArc = RitualArc(
|
||||||
|
startDate: calendar.date(byAdding: .day, value: 3, to: today) ?? today,
|
||||||
|
endDate: calendar.date(byAdding: .day, value: 1, to: today) ?? today,
|
||||||
|
arcNumber: 3,
|
||||||
|
isActive: false,
|
||||||
|
habits: []
|
||||||
|
)
|
||||||
|
|
||||||
|
var arcs = ritual.arcs ?? []
|
||||||
|
arcs.append(secondArc)
|
||||||
|
arcs.append(invalidArc)
|
||||||
|
ritual.arcs = arcs
|
||||||
|
|
||||||
|
store.runDataIntegrityMigrationIfNeeded(force: true)
|
||||||
|
|
||||||
|
#expect(ritual.sortIndex == 0)
|
||||||
|
#expect(invalidArc.endDate >= invalidArc.startDate)
|
||||||
|
#expect(firstArc.isActive == false)
|
||||||
|
#expect(secondArc.isActive == true)
|
||||||
|
#expect((ritual.arcs ?? []).filter { $0.isInProgress(on: today) }.count == 1)
|
||||||
|
|
||||||
|
for arc in ritual.arcs ?? [] {
|
||||||
|
let normalized = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||||
|
for (index, habit) in normalized.enumerated() {
|
||||||
|
#expect(habit.sortIndex == index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeStore() -> RitualStore {
|
private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
|
||||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
let container = SharedTestContainer.container
|
||||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
clearSharedTestContainer(container.mainContext)
|
||||||
let container: ModelContainer
|
|
||||||
do {
|
|
||||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
|
||||||
} catch {
|
|
||||||
fatalError("Test container failed: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService(), settingsStore: SettingsStore())
|
return RitualStore(
|
||||||
|
modelContext: container.mainContext,
|
||||||
|
seedService: EmptySeedService(),
|
||||||
|
settingsStore: TestFeedbackSettings(),
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum SharedTestContainer {
|
||||||
|
static let container: ModelContainer = {
|
||||||
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
|
let configuration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
isStoredInMemoryOnly: true,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try ModelContainer(for: schema, configurations: [configuration])
|
||||||
|
} catch {
|
||||||
|
fatalError("Test container failed: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func clearSharedTestContainer(_ context: ModelContext) {
|
||||||
|
do {
|
||||||
|
for habit in try context.fetch(FetchDescriptor<ArcHabit>()) {
|
||||||
|
context.delete(habit)
|
||||||
|
}
|
||||||
|
for arc in try context.fetch(FetchDescriptor<RitualArc>()) {
|
||||||
|
context.delete(arc)
|
||||||
|
}
|
||||||
|
for ritual in try context.fetch(FetchDescriptor<Ritual>()) {
|
||||||
|
context.delete(ritual)
|
||||||
|
}
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
fatalError("Failed to reset shared test container: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct EmptySeedService: RitualSeedProviding {
|
private struct EmptySeedService: RitualSeedProviding {
|
||||||
@ -208,6 +464,11 @@ private struct EmptySeedService: RitualSeedProviding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
|
||||||
|
var hapticsEnabled: Bool = false
|
||||||
|
var soundEnabled: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
private enum TestError: Error {
|
private enum TestError: Error {
|
||||||
case missingHabit
|
case missingHabit
|
||||||
case missingRitual
|
case missingRitual
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import XCTest
|
|||||||
final class AndromidaUITestsLaunchTests: XCTestCase {
|
final class AndromidaUITestsLaunchTests: XCTestCase {
|
||||||
|
|
||||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
true
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
|
|||||||
3
PRD.md
3
PRD.md
@ -262,6 +262,7 @@ URL scheme support for navigation.
|
|||||||
| TR-DATA-02 | Use NSUbiquitousKeyValueStore (via Bedrock CloudSyncManager) for settings sync |
|
| TR-DATA-02 | Use NSUbiquitousKeyValueStore (via Bedrock CloudSyncManager) for settings sync |
|
||||||
| TR-DATA-03 | Use UserDefaults for user-created categories and preferences |
|
| TR-DATA-03 | Use UserDefaults for user-created categories and preferences |
|
||||||
| TR-DATA-04 | Use App Group shared container for widget data access |
|
| TR-DATA-04 | Use App Group shared container for widget data access |
|
||||||
|
| TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
||||||
|
|
||||||
### 5.4 Third-Party Dependencies
|
### 5.4 Third-Party Dependencies
|
||||||
|
|
||||||
@ -590,6 +591,8 @@ Andromida/
|
|||||||
|-------------|-------------|
|
|-------------|-------------|
|
||||||
| Unit tests in `AndromidaTests/` covering store logic and analytics |
|
| Unit tests in `AndromidaTests/` covering store logic and analytics |
|
||||||
| UI tests in `AndromidaUITests/` for critical user flows |
|
| UI tests in `AndromidaUITests/` for critical user flows |
|
||||||
|
| Unit-test harness should use deterministic in-memory SwiftData setup to prevent host-app test instability |
|
||||||
|
| UI launch coverage should prioritize stable smoke validation over exhaustive simulator configuration matrices |
|
||||||
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
|
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -183,6 +183,9 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
|
|||||||
- Unit tests in `AndromidaTests/`
|
- Unit tests in `AndromidaTests/`
|
||||||
- Run via Xcode Test navigator or:
|
- Run via Xcode Test navigator or:
|
||||||
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'`
|
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'`
|
||||||
|
- XCTest runs disable SwiftData CloudKit mirroring in the host app to keep simulator tests deterministic.
|
||||||
|
- `RitualStoreTests` use a shared in-memory SwiftData container with per-test cleanup to avoid host-process container churn.
|
||||||
|
- `AndromidaUITestsLaunchTests` runs a single launch configuration to reduce flaky simulator timeouts.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@ -190,3 +193,5 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
|
|||||||
- The launch storyboard matches the branding primary color to avoid a white flash.
|
- The launch storyboard matches the branding primary color to avoid a white flash.
|
||||||
- App icon generation is available in DEBUG builds from Settings.
|
- App icon generation is available in DEBUG builds from Settings.
|
||||||
- Fresh installs start with no rituals; users create their own from scratch or presets.
|
- Fresh installs start with no rituals; users create their own from scratch or presets.
|
||||||
|
- A startup data-integrity migration normalizes arc date ranges, in-progress arc state, and sort indexes.
|
||||||
|
- Date-sensitive analytics in `RitualStore` are driven by an injectable time source for deterministic tests.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user