updates from codex

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-08 10:57:39 -06:00
parent 28c0282068
commit 469f960fec
11 changed files with 542 additions and 105 deletions

View File

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

View File

@ -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" : {

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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
View File

@ -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'` |
--- ---

View File

@ -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.