Compare commits
No commits in common. "edad031399dc725f3b4f10a428c4c301f8d078bc" and "e0d7fa750f3ed44c7c532abfb86612ad76b84d6b" have entirely different histories.
edad031399
...
e0d7fa750f
@ -53,7 +53,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EAC04A982F26BAE8007F87EA /* Rituals.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rituals.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAC04A982F26BAE8007F87EA /* Andromida.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Andromida.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AndromidaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@ -166,7 +166,7 @@
|
||||
EAC04A992F26BAE8007F87EA /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EAC04A982F26BAE8007F87EA /* Rituals.app */,
|
||||
EAC04A982F26BAE8007F87EA /* Andromida.app */,
|
||||
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */,
|
||||
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */,
|
||||
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */,
|
||||
@ -208,7 +208,7 @@
|
||||
EAC04AED2F26BD5B007F87EA /* Bedrock */,
|
||||
);
|
||||
productName = Andromida;
|
||||
productReference = EAC04A982F26BAE8007F87EA /* Rituals.app */;
|
||||
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EAC04AA42F26BAE9007F87EA /* AndromidaTests */ = {
|
||||
@ -629,7 +629,7 @@
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rituals.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Rituals";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Andromida.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Andromida";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@ -653,7 +653,7 @@
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rituals.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Rituals";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Andromida.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Andromida";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@ -29,37 +29,6 @@
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAC04A972F26BAE8007F87EA"
|
||||
BuildableName = "Rituals.app"
|
||||
BlueprintName = "Andromida"
|
||||
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAC04AA42F26BAE9007F87EA"
|
||||
BuildableName = "AndromidaTests.xctest"
|
||||
BlueprintName = "AndromidaTests"
|
||||
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAC04AAE2F26BAE9007F87EA"
|
||||
BuildableName = "AndromidaUITests.xctest"
|
||||
BlueprintName = "AndromidaUITests"
|
||||
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
|
||||
@ -24,43 +24,16 @@ struct AndromidaApp: App {
|
||||
)
|
||||
Theme.register(border: AppBorder.self)
|
||||
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil
|
||||
let isUITesting = environment["UITEST_MODE"] == "1"
|
||||
|
||||
if isUITesting {
|
||||
if environment["UITEST_RESET_USER_DEFAULTS"] == "1",
|
||||
let bundleIdentifier = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
|
||||
}
|
||||
if let completedValue = environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] {
|
||||
let hasCompleted = completedValue == "1" || completedValue.lowercased() == "true"
|
||||
UserDefaults.standard.set(hasCompleted, forKey: "hasCompletedSetupWizard")
|
||||
}
|
||||
}
|
||||
|
||||
// Include all models in schema - Ritual, RitualArc, and ArcHabit
|
||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||
|
||||
let configuration: ModelConfiguration
|
||||
if isUITesting {
|
||||
// UI tests should always run with isolated in-memory persistence.
|
||||
configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: true,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
} else {
|
||||
// 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")
|
||||
configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
url: storeURL,
|
||||
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||
)
|
||||
}
|
||||
// Use App Group for shared container between app and widget
|
||||
let configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
url: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
||||
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite"),
|
||||
cloudKitDatabase: .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||
)
|
||||
|
||||
let container: ModelContainer
|
||||
do {
|
||||
|
||||
@ -1716,18 +1716,6 @@
|
||||
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
|
||||
"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 %@ (%@)" : {
|
||||
"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" : {
|
||||
|
||||
@ -106,7 +106,7 @@ final class Ritual {
|
||||
|
||||
// Arcs - each arc represents a time-bound period with its own habits
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var arcs: [RitualArc]?
|
||||
var arcs: [RitualArc]? = []
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@ -134,16 +134,14 @@ final class Ritual {
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The most recent arc flagged as active.
|
||||
/// The currently active arc, if any.
|
||||
var currentArc: RitualArc? {
|
||||
(arcs ?? [])
|
||||
.filter { $0.isActive }
|
||||
.max { $0.startDate < $1.startDate }
|
||||
arcs?.first { $0.isActive }
|
||||
}
|
||||
|
||||
/// Whether this ritual has an active arc in progress.
|
||||
var hasActiveArc: Bool {
|
||||
activeArc(on: Date()) != nil
|
||||
currentArc != nil
|
||||
}
|
||||
|
||||
/// All arcs sorted by start date (newest first).
|
||||
@ -158,12 +156,12 @@ final class Ritual {
|
||||
|
||||
/// Total number of completed arcs.
|
||||
var completedArcCount: Int {
|
||||
completedArcs(asOf: Date()).count
|
||||
(arcs ?? []).filter { !$0.isActive }.count
|
||||
}
|
||||
|
||||
/// The end date of the most recently completed arc, if any.
|
||||
var lastCompletedDate: Date? {
|
||||
completedArcs(asOf: Date())
|
||||
(arcs ?? []).filter { !$0.isActive }
|
||||
.sorted { $0.endDate > $1.endDate }
|
||||
.first?.endDate
|
||||
}
|
||||
@ -172,43 +170,29 @@ final class Ritual {
|
||||
|
||||
/// Habits from the current arc sorted by sortIndex (empty if no active arc).
|
||||
var habits: [ArcHabit] {
|
||||
(activeArc(on: Date())?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||
(currentArc?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||
}
|
||||
|
||||
/// Start date of the current arc.
|
||||
var startDate: Date {
|
||||
activeArc(on: Date())?.startDate ?? Date()
|
||||
currentArc?.startDate ?? Date()
|
||||
}
|
||||
|
||||
/// Duration of the current arc in days.
|
||||
var durationDays: Int {
|
||||
activeArc(on: Date())?.durationDays ?? defaultDurationDays
|
||||
currentArc?.durationDays ?? defaultDurationDays
|
||||
}
|
||||
|
||||
/// End date of the current arc.
|
||||
var endDate: Date {
|
||||
activeArc(on: Date())?.endDate ?? Date()
|
||||
currentArc?.endDate ?? Date()
|
||||
}
|
||||
|
||||
// MARK: - Arc Queries
|
||||
|
||||
/// Returns the active in-progress arc for a date, if any.
|
||||
func activeArc(on date: Date) -> RitualArc? {
|
||||
(arcs ?? [])
|
||||
.filter { $0.isInProgress(on: date) }
|
||||
.max { $0.startDate < $1.startDate }
|
||||
}
|
||||
|
||||
/// Returns arcs that should be treated as completed as of a date.
|
||||
func completedArcs(asOf date: Date = Date()) -> [RitualArc] {
|
||||
(arcs ?? []).filter { $0.isCompleted(asOf: date) }
|
||||
}
|
||||
|
||||
/// Returns the arc that was active on a specific date, if any.
|
||||
func arc(for date: Date) -> RitualArc? {
|
||||
(arcs ?? [])
|
||||
.filter { $0.contains(date: date) }
|
||||
.max { $0.startDate < $1.startDate }
|
||||
(arcs ?? []).first { $0.contains(date: date) }
|
||||
}
|
||||
|
||||
/// Returns all arcs that overlap with a date range.
|
||||
|
||||
@ -13,7 +13,7 @@ final class RitualArc {
|
||||
var isActive: Bool = true
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var habits: [ArcHabit]?
|
||||
var habits: [ArcHabit]? = []
|
||||
|
||||
@Relationship(inverse: \Ritual.arcs)
|
||||
var ritual: Ritual?
|
||||
@ -70,22 +70,6 @@ final class RitualArc {
|
||||
let end = calendar.startOfDay(for: endDate)
|
||||
return checkDate >= start && checkDate <= end
|
||||
}
|
||||
|
||||
/// Whether this arc is currently in progress for the provided date.
|
||||
func isInProgress(on date: Date = Date(), calendar: Calendar = .current) -> Bool {
|
||||
isActive && contains(date: calendar.startOfDay(for: date))
|
||||
}
|
||||
|
||||
/// Whether this arc should be treated as completed as of the provided date.
|
||||
/// This includes explicitly ended arcs and active arcs that have passed their end date.
|
||||
func isCompleted(asOf date: Date = Date(), calendar: Calendar = .current) -> Bool {
|
||||
if !isActive {
|
||||
return true
|
||||
}
|
||||
let today = calendar.startOfDay(for: date)
|
||||
let arcEnd = calendar.startOfDay(for: endDate)
|
||||
return arcEnd < today
|
||||
}
|
||||
|
||||
/// Returns the day index (1-based) for a given date within this arc.
|
||||
func dayIndex(for date: Date) -> Int {
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal settings surface RitualStore needs for check-in feedback behavior.
|
||||
protocol RitualFeedbackSettingsProviding {
|
||||
var hapticsEnabled: Bool { get }
|
||||
var soundEnabled: Bool { get }
|
||||
}
|
||||
@ -21,7 +21,7 @@ final class CategoryStore {
|
||||
|
||||
/// Get a category by name
|
||||
func category(named name: String) -> Category? {
|
||||
categories.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }
|
||||
categories.first { $0.name == name }
|
||||
}
|
||||
|
||||
/// Get color for a category name, with fallback
|
||||
@ -35,7 +35,7 @@ final class CategoryStore {
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
// Don't add if name already exists
|
||||
guard isNameAvailable(trimmedName) else { return }
|
||||
guard category(named: trimmedName) == nil else { return }
|
||||
|
||||
let newCategory = Category.create(name: trimmedName, colorName: colorName)
|
||||
categories.append(newCategory)
|
||||
@ -58,7 +58,7 @@ final class CategoryStore {
|
||||
if let name {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Ensure no duplicate names
|
||||
if !trimmedName.isEmpty && self.isNameAvailable(trimmedName, excluding: categories[index]) {
|
||||
if !trimmedName.isEmpty && self.category(named: trimmedName) == nil {
|
||||
categories[index].name = trimmedName
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,15 +8,10 @@ import WidgetKit
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RitualStore: RitualStoreProviding {
|
||||
private static let dataIntegrityMigrationVersion = 1
|
||||
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
|
||||
|
||||
@ObservationIgnored private let modelContext: ModelContext
|
||||
@ObservationIgnored private let seedService: RitualSeedProviding
|
||||
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
|
||||
@ObservationIgnored private let settingsStore: SettingsStore
|
||||
@ObservationIgnored private let calendar: Calendar
|
||||
@ObservationIgnored private let nowProvider: () -> Date
|
||||
@ObservationIgnored private let isRunningTests: Bool
|
||||
@ObservationIgnored private let dayFormatter: DateFormatter
|
||||
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
||||
@ -40,7 +35,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
var ritualNeedingRenewal: Ritual?
|
||||
|
||||
/// The current time of day, updated periodically. Observable for UI refresh.
|
||||
private(set) var currentTimeOfDay: TimeOfDay = .anytime
|
||||
private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current()
|
||||
|
||||
/// Debug override for time of day (nil = use real time)
|
||||
var debugTimeOfDayOverride: TimeOfDay? {
|
||||
@ -58,16 +53,13 @@ final class RitualStore: RitualStoreProviding {
|
||||
init(
|
||||
modelContext: ModelContext,
|
||||
seedService: RitualSeedProviding,
|
||||
settingsStore: any RitualFeedbackSettingsProviding,
|
||||
calendar: Calendar = .current,
|
||||
now: @escaping () -> Date = Date.init
|
||||
settingsStore: SettingsStore,
|
||||
calendar: Calendar = .current
|
||||
) {
|
||||
self.modelContext = modelContext
|
||||
self.seedService = seedService
|
||||
self.settingsStore = settingsStore
|
||||
self.calendar = calendar
|
||||
self.nowProvider = now
|
||||
self.isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||
self.dayFormatter = DateFormatter()
|
||||
self.displayFormatter = DateFormatter()
|
||||
dayFormatter.calendar = calendar
|
||||
@ -75,16 +67,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
displayFormatter.calendar = calendar
|
||||
displayFormatter.dateStyle = .full
|
||||
displayFormatter.timeStyle = .none
|
||||
self.currentTimeOfDay = TimeOfDay.current(for: now())
|
||||
runDataIntegrityMigrationIfNeeded()
|
||||
loadRitualsIfNeeded()
|
||||
if !isRunningTests {
|
||||
observeRemoteChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func now() -> Date {
|
||||
nowProvider()
|
||||
observeRemoteChanges()
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -111,11 +95,15 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
var activeRitual: Ritual? {
|
||||
currentRituals.first
|
||||
// Return the first ritual with an active arc that covers today
|
||||
currentRituals.first { ritual in
|
||||
guard let arc = ritual.currentArc else { return false }
|
||||
return arc.contains(date: Date())
|
||||
}
|
||||
}
|
||||
|
||||
var todayDisplayString: String {
|
||||
displayFormatter.string(from: now())
|
||||
displayFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
var activeRitualProgress: Double {
|
||||
@ -136,7 +124,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
/// Updates the current time of day and returns true if it changed.
|
||||
@discardableResult
|
||||
func updateCurrentTimeOfDay() -> Bool {
|
||||
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
|
||||
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current()
|
||||
if newTimeOfDay != currentTimeOfDay {
|
||||
currentTimeOfDay = newTimeOfDay
|
||||
return true
|
||||
@ -146,16 +134,16 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the effective time of day (considering debug override).
|
||||
func effectiveTimeOfDay() -> TimeOfDay {
|
||||
debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
|
||||
debugTimeOfDayOverride ?? TimeOfDay.current()
|
||||
}
|
||||
|
||||
/// Refreshes rituals if the last refresh was beyond the minimum interval.
|
||||
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
|
||||
let currentDate = now()
|
||||
if let lastRefreshDate, currentDate.timeIntervalSince(lastRefreshDate) < minimumInterval {
|
||||
let now = Date()
|
||||
if let lastRefreshDate, now.timeIntervalSince(lastRefreshDate) < minimumInterval {
|
||||
return
|
||||
}
|
||||
lastRefreshDate = currentDate
|
||||
lastRefreshDate = now
|
||||
refresh()
|
||||
}
|
||||
|
||||
@ -171,15 +159,15 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
|
||||
let dayID = dayIdentifier(for: now())
|
||||
let dayID = dayIdentifier(for: Date())
|
||||
return habit.completedDayIDs.contains(dayID)
|
||||
}
|
||||
|
||||
func toggleHabitCompletion(_ habit: ArcHabit) {
|
||||
toggleHabitCompletion(habit, date: now())
|
||||
toggleHabitCompletion(habit, date: Date())
|
||||
}
|
||||
|
||||
func toggleHabitCompletion(_ habit: ArcHabit, date: Date) {
|
||||
func toggleHabitCompletion(_ habit: ArcHabit, date: Date = Date()) {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
||||
|
||||
@ -199,9 +187,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
func ritualDayIndex(for ritual: Ritual) -> Int {
|
||||
let currentDate = now()
|
||||
guard let arc = ritual.activeArc(on: currentDate) else { return 0 }
|
||||
return arc.dayIndex(for: currentDate)
|
||||
guard let arc = ritual.currentArc else { return 0 }
|
||||
return arc.dayIndex(for: Date())
|
||||
}
|
||||
|
||||
func ritualDayLabel(for ritual: Ritual) -> String {
|
||||
@ -230,11 +217,11 @@ final class RitualStore: RitualStoreProviding {
|
||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
|
||||
func ritualsForToday() -> [Ritual] {
|
||||
let today = now()
|
||||
let today = Date()
|
||||
let timeOfDay = effectiveTimeOfDay()
|
||||
|
||||
return currentRituals.filter { ritual in
|
||||
guard ritual.activeArc(on: today) != nil else {
|
||||
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
|
||||
return false
|
||||
}
|
||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
||||
@ -262,24 +249,22 @@ final class RitualStore: RitualStoreProviding {
|
||||
func habitsActive(on date: Date) -> [ArcHabit] {
|
||||
arcsActive(on: date).flatMap { $0.habits ?? [] }
|
||||
}
|
||||
|
||||
/// Returns habits from arcs currently in progress on the provided date.
|
||||
private func habitsInProgress(on date: Date) -> [ArcHabit] {
|
||||
currentRituals
|
||||
.compactMap { $0.activeArc(on: date) }
|
||||
.flatMap { $0.habits ?? [] }
|
||||
}
|
||||
|
||||
/// Checks if a ritual's current arc has completed (past end date).
|
||||
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
||||
guard let arc = ritual.currentArc else { return false }
|
||||
return arc.isCompleted(asOf: now(), calendar: calendar)
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||
return today > endDate
|
||||
}
|
||||
|
||||
/// Checks for rituals that need renewal and triggers the prompt.
|
||||
func checkForCompletedArcs() {
|
||||
ritualNeedingRenewal = rituals.first { ritual in
|
||||
isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID)
|
||||
for ritual in currentRituals {
|
||||
if isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID) {
|
||||
ritualNeedingRenewal = ritual
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -309,7 +294,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
let newArc = RitualArc(
|
||||
startDate: now(),
|
||||
startDate: Date(),
|
||||
durationDays: duration,
|
||||
arcNumber: newArcNumber,
|
||||
isActive: true,
|
||||
@ -359,7 +344,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||
func currentStreak() -> Int {
|
||||
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar, currentDate: now())
|
||||
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Calculates the longest streak of consecutive perfect days
|
||||
@ -395,7 +380,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns completion data for the last 7 days for a trend chart
|
||||
func weeklyTrendData() -> [TrendDataPoint] {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let shortWeekdayFormatter = DateFormatter()
|
||||
shortWeekdayFormatter.calendar = calendar
|
||||
shortWeekdayFormatter.dateFormat = "EEE"
|
||||
@ -493,13 +478,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
private func computeInsightCards() -> [InsightCard] {
|
||||
let currentDate = now()
|
||||
let inProgressRituals = currentRituals.filter { ritual in
|
||||
ritual.activeArc(on: currentDate) != nil
|
||||
}
|
||||
|
||||
// Only count habits from active arcs for today's stats
|
||||
let activeHabitsToday = habitsInProgress(on: currentDate)
|
||||
let activeHabitsToday = habitsActive(on: Date())
|
||||
let totalHabits = activeHabitsToday.count
|
||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||
@ -508,10 +488,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
let daysActiveCount = datesWithActivity().count
|
||||
|
||||
// Count rituals with active arcs
|
||||
let activeRitualCount = inProgressRituals.count
|
||||
let activeRitualCount = currentRituals.count
|
||||
|
||||
// Build per-ritual progress breakdown
|
||||
let habitsBreakdown = inProgressRituals.map { ritual in
|
||||
let habitsBreakdown = currentRituals.map { ritual in
|
||||
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||
return BreakdownItem(
|
||||
label: ritual.title,
|
||||
@ -545,15 +525,15 @@ final class RitualStore: RitualStoreProviding {
|
||||
// Best ritual by completion rate
|
||||
let bestRitualInfo: (title: String, rate: Int)? = {
|
||||
var best: (title: String, rate: Int)?
|
||||
for ritual in inProgressRituals {
|
||||
guard let arc = ritual.activeArc(on: currentDate) else { continue }
|
||||
for ritual in currentRituals {
|
||||
guard let arc = ritual.currentArc else { continue }
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { continue }
|
||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
let possibleCheckIns = habits.count * arc.dayIndex(for: currentDate)
|
||||
let possibleCheckIns = habits.count * arc.dayIndex(for: Date())
|
||||
guard possibleCheckIns > 0 else { continue }
|
||||
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
||||
if best.map({ rate > $0.rate }) ?? true {
|
||||
if best == nil || rate > best!.rate {
|
||||
best = (ritual.title, rate)
|
||||
}
|
||||
}
|
||||
@ -569,7 +549,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
caption: String(localized: "In progress now"),
|
||||
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
|
||||
symbolName: "sparkles",
|
||||
breakdown: inProgressRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||
),
|
||||
.streak: InsightCard(
|
||||
type: .streak,
|
||||
@ -694,67 +674,24 @@ final class RitualStore: RitualStoreProviding {
|
||||
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1),
|
||||
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
|
||||
]
|
||||
_ = createRitualWithInitialArc(
|
||||
let arc = RitualArc(
|
||||
startDate: Date(),
|
||||
durationDays: defaultDuration,
|
||||
arcNumber: 1,
|
||||
isActive: true,
|
||||
habits: habits
|
||||
)
|
||||
let nextSortIndex = rituals.count
|
||||
let ritual = Ritual(
|
||||
title: String(localized: "Custom Ritual"),
|
||||
theme: String(localized: "Your next chapter"),
|
||||
defaultDurationDays: defaultDuration,
|
||||
notes: String(localized: "A fresh ritual created from your focus today."),
|
||||
timeOfDay: .anytime,
|
||||
iconName: "sparkles",
|
||||
category: "",
|
||||
sortIndex: rituals.count,
|
||||
durationDays: defaultDuration,
|
||||
habits: habits
|
||||
sortIndex: nextSortIndex,
|
||||
arcs: [arc]
|
||||
)
|
||||
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
|
||||
saveContext()
|
||||
}
|
||||
|
||||
/// Creates a new ritual with the given properties
|
||||
@ -768,7 +705,20 @@ final class RitualStore: RitualStoreProviding {
|
||||
category: String = "",
|
||||
habits: [ArcHabit] = []
|
||||
) {
|
||||
_ = createRitualWithInitialArc(
|
||||
// Assign sortIndex to habits if not already set
|
||||
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,
|
||||
theme: theme,
|
||||
defaultDurationDays: durationDays,
|
||||
@ -776,10 +726,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
timeOfDay: timeOfDay,
|
||||
iconName: iconName,
|
||||
category: category,
|
||||
sortIndex: rituals.count,
|
||||
durationDays: durationDays,
|
||||
habits: habits
|
||||
sortIndex: nextSortIndex,
|
||||
arcs: [arc]
|
||||
)
|
||||
modelContext.insert(ritual)
|
||||
saveContext()
|
||||
}
|
||||
|
||||
@ -807,8 +757,15 @@ final class RitualStore: RitualStoreProviding {
|
||||
let habits = preset.habits.enumerated().map { index, habitPreset in
|
||||
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
|
||||
}
|
||||
|
||||
let ritual = createRitualWithInitialArc(
|
||||
let arc = RitualArc(
|
||||
startDate: Date(),
|
||||
durationDays: preset.durationDays,
|
||||
arcNumber: 1,
|
||||
isActive: true,
|
||||
habits: habits
|
||||
)
|
||||
let nextSortIndex = rituals.count
|
||||
let ritual = Ritual(
|
||||
title: preset.title,
|
||||
theme: preset.theme,
|
||||
defaultDurationDays: preset.durationDays,
|
||||
@ -816,10 +773,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
timeOfDay: preset.timeOfDay,
|
||||
iconName: preset.iconName,
|
||||
category: preset.category,
|
||||
sortIndex: rituals.count,
|
||||
durationDays: preset.durationDays,
|
||||
habits: habits
|
||||
sortIndex: nextSortIndex,
|
||||
arcs: [arc]
|
||||
)
|
||||
modelContext.insert(ritual)
|
||||
saveContext()
|
||||
return ritual
|
||||
}
|
||||
@ -844,7 +801,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
ritual.category = category
|
||||
|
||||
// Also update the current arc's end date if duration changed
|
||||
if let currentArc = ritual.activeArc(on: now()), currentArc.durationDays != durationDays {
|
||||
if let currentArc = ritual.currentArc, currentArc.durationDays != durationDays {
|
||||
let newEndDate = calendar.date(byAdding: .day, value: durationDays - 1, to: currentArc.startDate) ?? currentArc.endDate
|
||||
currentArc.endDate = newEndDate
|
||||
}
|
||||
@ -860,7 +817,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Adds a habit to the current arc of a ritual
|
||||
func addHabit(to ritual: Ritual, title: String, symbolName: String) {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return }
|
||||
guard let arc = ritual.currentArc else { return }
|
||||
let habits = arc.habits ?? []
|
||||
let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0
|
||||
let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex)
|
||||
@ -872,7 +829,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Removes a habit from the current arc of a ritual
|
||||
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return }
|
||||
guard let arc = ritual.currentArc else { return }
|
||||
var habits = arc.habits ?? []
|
||||
habits.removeAll { $0.id == habit.id }
|
||||
arc.habits = habits
|
||||
@ -886,108 +843,6 @@ final class RitualStore: RitualStoreProviding {
|
||||
// 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() {
|
||||
do {
|
||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||
@ -1003,10 +858,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
do {
|
||||
try modelContext.save()
|
||||
reloadRituals()
|
||||
// Widget timeline reloads can destabilize test hosts; skip in tests.
|
||||
if !isRunningTests {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
// Notify widgets that data has changed
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Trigger a UI refresh for observation-based views
|
||||
analyticsNeedsRefresh = true
|
||||
insightCardsNeedRefresh = true
|
||||
@ -1016,9 +869,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
private func updateDerivedData() {
|
||||
let currentDate = now()
|
||||
currentRituals = rituals
|
||||
.filter { $0.activeArc(on: currentDate) != nil }
|
||||
.filter { $0.hasActiveArc }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timeOfDay != rhs.timeOfDay {
|
||||
return lhs.timeOfDay < rhs.timeOfDay
|
||||
@ -1026,7 +878,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
return lhs.sortIndex < rhs.sortIndex
|
||||
}
|
||||
pastRituals = rituals
|
||||
.filter { $0.activeArc(on: currentDate) == nil }
|
||||
.filter { !$0.hasActiveArc }
|
||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||
}
|
||||
|
||||
@ -1081,8 +933,6 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
private func scheduleReminderUpdate() {
|
||||
// Avoid touching live SwiftData model graphs from async reminder tasks during XCTest runs.
|
||||
guard !isRunningTests else { return }
|
||||
pendingReminderTask?.cancel()
|
||||
let ritualsSnapshot = rituals
|
||||
pendingReminderTask = Task {
|
||||
@ -1217,8 +1067,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the number of days remaining in the ritual's current arc.
|
||||
func daysRemaining(for ritual: Ritual) -> Int {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||
let today = calendar.startOfDay(for: now())
|
||||
guard let arc = ritual.currentArc else { return 0 }
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||
|
||||
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
||||
@ -1227,12 +1077,12 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the current streak for a specific ritual's current arc.
|
||||
func streakForRitual(_ ritual: Ritual) -> Int {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||
guard let arc = ritual.currentArc else { return 0 }
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
|
||||
var streak = 0
|
||||
var checkDate = calendar.startOfDay(for: now())
|
||||
var checkDate = calendar.startOfDay(for: Date())
|
||||
|
||||
while arc.contains(date: checkDate) {
|
||||
let dayID = dayIdentifier(for: checkDate)
|
||||
@ -1253,11 +1103,11 @@ final class RitualStore: RitualStoreProviding {
|
||||
/// Returns nil if there's no previous arc to compare against.
|
||||
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
||||
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
||||
let currentDate = now()
|
||||
guard let currentArc = ritual.activeArc(on: currentDate) else { return nil }
|
||||
guard let currentArc = ritual.currentArc else { return nil }
|
||||
|
||||
// Find the previous arc (most recent completed arc)
|
||||
let completedArcs = ritual.completedArcs(asOf: currentDate)
|
||||
let completedArcs = (ritual.arcs ?? [])
|
||||
.filter { !$0.isActive }
|
||||
.sorted { $0.arcNumber > $1.arcNumber }
|
||||
|
||||
guard let previousArc = completedArcs.first else { return nil }
|
||||
@ -1415,7 +1265,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the week-over-week change in completion rate.
|
||||
func weekOverWeekChange() -> Double {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
||||
return 0
|
||||
}
|
||||
@ -1447,7 +1297,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
|
||||
/// Returns the insight context for tips generation.
|
||||
func insightContext() -> InsightContext {
|
||||
let activeHabitsToday = habitsInProgress(on: now())
|
||||
let activeHabitsToday = habitsActive(on: Date())
|
||||
let totalHabits = activeHabitsToday.count
|
||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
||||
@ -1472,7 +1322,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
#if DEBUG
|
||||
/// Preloads 6 months of random completion data for testing the history view.
|
||||
func preloadDemoData() {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
|
||||
// Go back 6 months
|
||||
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
||||
@ -1555,13 +1405,13 @@ final class RitualStore: RitualStoreProviding {
|
||||
func simulateArcCompletion() {
|
||||
// Find the first ritual with an active arc
|
||||
guard let ritual = currentRituals.first,
|
||||
let arc = ritual.activeArc(on: now()) else {
|
||||
let arc = ritual.currentArc else {
|
||||
print("No active arcs to complete")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the end date to yesterday so the arc appears completed
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now())) ?? now()
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())) ?? Date()
|
||||
arc.endDate = yesterday
|
||||
|
||||
// Also backdate the start date so the arc looks like it ran for a reasonable period
|
||||
|
||||
@ -4,7 +4,7 @@ import Bedrock
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsStore: CloudSyncable, ThemeProviding, RitualFeedbackSettingsProviding {
|
||||
final class SettingsStore: CloudSyncable, ThemeProviding {
|
||||
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
||||
@ObservationIgnored private var cloudChangeObserver: NSObjectProtocol?
|
||||
@ObservationIgnored private var isApplyingCloudUpdate = false
|
||||
|
||||
@ -27,6 +27,11 @@ struct HistoryView: View {
|
||||
private let baseMonthsToShow = 2
|
||||
private let monthChunkSize = 6
|
||||
|
||||
/// Whether to use wide layout (2 columns) on iPad/landscape
|
||||
private var useWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Grid columns for month cards - 2 columns on regular width, 1 on compact
|
||||
private var monthColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
|
||||
@ -108,12 +108,10 @@ struct FirstCheckInStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.firstCheckInContinue")
|
||||
|
||||
Button(action: onComplete) {
|
||||
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.firstCheckInSkip")
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
@ -157,7 +155,6 @@ struct FirstCheckInStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
@ -62,7 +62,6 @@ struct GoalSelectionStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.goalContinue")
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
@ -127,11 +126,8 @@ private struct GoalCardView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(goal.displayName)
|
||||
.accessibilityHint(goal.subtitle)
|
||||
.accessibilityIdentifier("onboarding.goal.\(goal.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -85,7 +85,6 @@ struct NotificationStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.enableNotifications")
|
||||
.disabled(isRequestingPermission)
|
||||
|
||||
// Secondary skip option
|
||||
@ -94,7 +93,6 @@ struct NotificationStepView: View {
|
||||
.typography(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.notificationsMaybeLater")
|
||||
.disabled(isRequestingPermission)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
|
||||
@ -90,13 +90,11 @@ struct RitualPreviewStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.startRitual")
|
||||
|
||||
// Skip option
|
||||
Button(action: onSkip) {
|
||||
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.skipRitual")
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.opacity(animateContent ? 1 : 0)
|
||||
|
||||
@ -62,7 +62,6 @@ struct TimeSelectionStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.timeContinue")
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.bottom, Design.Spacing.xxLarge)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
@ -132,11 +131,8 @@ private struct TimeCardView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(time.displayName)
|
||||
.accessibilityHint(time.subtitle)
|
||||
.accessibilityIdentifier("onboarding.time.\(time.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,6 @@ struct WelcomeStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.getStarted")
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.opacity(animateButton ? 1 : 0)
|
||||
.offset(y: animateButton ? 0 : 20)
|
||||
|
||||
@ -74,7 +74,6 @@ struct WhatsNextStepView: View {
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
.accessibilityIdentifier("onboarding.letsGo")
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.opacity(animateContent ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)
|
||||
|
||||
@ -26,6 +26,11 @@ struct ArcDetailView: View {
|
||||
|
||||
private let calendar = Calendar.current
|
||||
|
||||
/// Whether to use wide layout on iPad/landscape
|
||||
private var useWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Grid columns for month calendars - 2 columns on regular width when multiple months
|
||||
private var monthColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
@ -115,6 +120,22 @@ struct ArcDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns habit completions for a specific date within this arc
|
||||
private func arcHabitCompletions(for date: Date) -> [HabitCompletion] {
|
||||
let habits = arc.habits ?? []
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dayID = dayFormatter.string(from: date)
|
||||
|
||||
return habits.map { habit in
|
||||
HabitCompletion(
|
||||
habit: habit,
|
||||
ritualTitle: ritual.title,
|
||||
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns completion rate for a date within this arc
|
||||
private func arcCompletionRate(for date: Date) -> Double {
|
||||
let habits = arc.habits ?? []
|
||||
@ -123,7 +144,9 @@ struct ArcDetailView: View {
|
||||
// Only return rate if date is within arc range
|
||||
guard arc.contains(date: date) else { return 0 }
|
||||
|
||||
let dayID = RitualAnalytics.dayIdentifier(for: date)
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dayID = dayFormatter.string(from: date)
|
||||
|
||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||
return Double(completed) / Double(habits.count)
|
||||
|
||||
@ -20,6 +20,11 @@ struct RitualDetailView: View {
|
||||
self.ritual = ritual
|
||||
}
|
||||
|
||||
/// Whether to use wide layout on iPad/landscape
|
||||
private var useWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Grid columns for habits - 2 columns on regular width when multiple habits
|
||||
private var habitColumns: [GridItem] {
|
||||
let habits = store.habits(for: ritual)
|
||||
@ -62,7 +67,7 @@ struct RitualDetailView: View {
|
||||
}
|
||||
|
||||
private var completedArcs: [RitualArc] {
|
||||
ritual.completedArcs(asOf: Date()).sorted { $0.startDate > $1.startDate }
|
||||
(ritual.arcs ?? []).filter { !$0.isActive }.sorted { $0.startDate > $1.startDate }
|
||||
}
|
||||
|
||||
private var arcComparisonInfo: (text: String, isAhead: Bool, isBehind: Bool)? {
|
||||
@ -306,7 +311,7 @@ struct RitualDetailView: View {
|
||||
private var statusBadges: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Current arc indicator
|
||||
if let arc = ritual.activeArc(on: Date()) {
|
||||
if let arc = ritual.currentArc {
|
||||
Text(String(localized: "Arc \(arc.arcNumber)")).styled(.captionEmphasis, emphasis: .custom(AppAccent.primary))
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
|
||||
@ -72,20 +72,16 @@ struct RitualsView: View {
|
||||
} label: {
|
||||
Label(String(localized: "Create New"), systemImage: "plus.circle")
|
||||
}
|
||||
.accessibilityIdentifier("rituals.createNew")
|
||||
|
||||
Button {
|
||||
showingPresetLibrary = true
|
||||
} label: {
|
||||
Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack")
|
||||
}
|
||||
.accessibilityIdentifier("rituals.browsePresets")
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.accessibilityIdentifier("rituals.addMenu")
|
||||
}
|
||||
.accessibilityIdentifier("rituals.addMenu")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPresetLibrary) {
|
||||
|
||||
@ -62,7 +62,6 @@ struct RitualEditSheet: View {
|
||||
Button(String(localized: "Cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("ritualEditor.cancelButton")
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
|
||||
@ -71,7 +70,6 @@ struct RitualEditSheet: View {
|
||||
saveRitual()
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("ritualEditor.saveButton")
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
@ -114,12 +112,10 @@ struct RitualEditSheet: View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
TextField(String(localized: "Ritual name"), text: $title)
|
||||
.typography(.heading)
|
||||
.accessibilityIdentifier("ritualEditor.titleField")
|
||||
|
||||
TextField(String(localized: "Theme or tagline"), text: $theme)
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.accessibilityIdentifier("ritualEditor.themeField")
|
||||
}
|
||||
}
|
||||
.listRowBackground(AppSurface.card)
|
||||
@ -300,7 +296,6 @@ struct RitualEditSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
|
||||
.accessibilityIdentifier("ritualEditor.newHabitField")
|
||||
.onSubmit {
|
||||
addNewHabit()
|
||||
}
|
||||
@ -311,7 +306,6 @@ struct RitualEditSheet: View {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
.accessibilityIdentifier("ritualEditor.addHabitButton")
|
||||
.buttonStyle(.plain)
|
||||
.disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
@ -6,19 +6,19 @@ struct TodayNoRitualsForTimeView: View {
|
||||
@Bindable var store: RitualStore
|
||||
|
||||
private var currentTimePeriod: TimeOfDay {
|
||||
store.effectiveTimeOfDay()
|
||||
TimeOfDay.current()
|
||||
}
|
||||
|
||||
private var nextRituals: [Ritual] {
|
||||
// Find rituals scheduled for later time periods TODAY
|
||||
store.currentRituals.filter { ritual in
|
||||
guard ritual.activeArc(on: Date()) != nil else { return false }
|
||||
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
||||
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||
}
|
||||
}
|
||||
|
||||
private var nextRitualContext: (ritual: Ritual, isTomorrow: Bool)? {
|
||||
RitualAnalytics.nextUpcomingRitualContext(from: store.currentRituals)
|
||||
private var nextRitualTomorrow: Ritual? {
|
||||
RitualAnalytics.nextUpcomingRitual(from: store.currentRituals)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -71,30 +71,17 @@ struct TodayNoRitualsForTimeView: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, Design.Spacing.small)
|
||||
} else if let nextContext = nextRitualContext {
|
||||
if nextContext.isTomorrow {
|
||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||
Text(String.localizedStringWithFormat(
|
||||
format,
|
||||
nextContext.ritual.timeOfDay.displayName,
|
||||
nextContext.ritual.timeOfDay.timeRange
|
||||
))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
} else {
|
||||
let format = String(localized: "Next ritual: %@ (%@)")
|
||||
Text(String.localizedStringWithFormat(
|
||||
format,
|
||||
nextContext.ritual.timeOfDay.displayName,
|
||||
nextContext.ritual.timeOfDay.timeRange
|
||||
))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
}
|
||||
} else if let tomorrowRitual = nextRitualTomorrow {
|
||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||
Text(String.localizedStringWithFormat(
|
||||
format,
|
||||
tomorrowRitual.timeOfDay.displayName,
|
||||
tomorrowRitual.timeOfDay.timeRange
|
||||
))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
}
|
||||
|
||||
// Motivational message
|
||||
|
||||
@ -3,37 +3,51 @@ import Foundation
|
||||
/// Shared logic for ritual analytics and data processing used by both the app and widgets.
|
||||
enum RitualAnalytics {
|
||||
/// Returns a unique string identifier for a given date (YYYY-MM-DD).
|
||||
static func dayIdentifier(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let localDate = calendar.startOfDay(for: date)
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: localDate)
|
||||
let year = components.year ?? 0
|
||||
let month = components.month ?? 0
|
||||
let day = components.day ?? 0
|
||||
return String(format: "%04d-%02d-%02d", year, month, day)
|
||||
static func dayIdentifier(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// Calculates the current streak of consecutive perfect days (100% completion).
|
||||
/// Calculates the current streak of consecutive days with activity.
|
||||
/// - Parameters:
|
||||
/// - rituals: The list of rituals to analyze.
|
||||
/// - calendar: The calendar to use for date calculations.
|
||||
/// - currentDate: The date used as "today" for streak evaluation.
|
||||
/// - Returns: The current streak count.
|
||||
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current, currentDate: Date = Date()) -> Int {
|
||||
// Count backwards from today using perfect days (100% completion).
|
||||
var streak = 0
|
||||
var checkDate = calendar.startOfDay(for: currentDate)
|
||||
|
||||
// If today isn't perfect, start from yesterday.
|
||||
if !isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int {
|
||||
var allCompletions = Set<String>()
|
||||
|
||||
// Collect all completed day IDs from all habits across all rituals
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs ?? [] {
|
||||
for habit in arc.habits ?? [] {
|
||||
for dID in habit.completedDayIDs {
|
||||
allCompletions.insert(dID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {
|
||||
|
||||
if allCompletions.isEmpty { return 0 }
|
||||
|
||||
// Count backwards from today
|
||||
var streak = 0
|
||||
var checkDate = calendar.startOfDay(for: Date())
|
||||
|
||||
// If today isn't perfect, check if yesterday was to maintain streak
|
||||
var currentDayID = dayIdentifier(for: checkDate)
|
||||
if !allCompletions.contains(currentDayID) {
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
currentDayID = dayIdentifier(for: checkDate)
|
||||
}
|
||||
|
||||
while allCompletions.contains(currentDayID) {
|
||||
streak += 1
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
currentDayID = dayIdentifier(for: checkDate)
|
||||
if streak > 3650 { break } // Safety cap (10 years)
|
||||
}
|
||||
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
@ -42,7 +56,7 @@ enum RitualAnalytics {
|
||||
let timeOfDay = TimeOfDay.current(for: date)
|
||||
|
||||
return rituals.filter { ritual in
|
||||
guard ritual.activeArc(on: date) != nil else {
|
||||
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) != nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -56,7 +70,7 @@ enum RitualAnalytics {
|
||||
var allTodayHabits: [ArcHabit] = []
|
||||
|
||||
for ritual in rituals {
|
||||
if let arc = ritual.arc(for: date) {
|
||||
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) {
|
||||
allTodayHabits.append(contentsOf: arc.habits ?? [])
|
||||
}
|
||||
}
|
||||
@ -67,53 +81,27 @@ enum RitualAnalytics {
|
||||
return Double(completedCount) / Double(allTodayHabits.count)
|
||||
}
|
||||
|
||||
/// Finds the next upcoming ritual and whether it occurs tomorrow.
|
||||
static func nextUpcomingRitualContext(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> (ritual: Ritual, isTomorrow: Bool)? {
|
||||
/// Finds the next upcoming ritual (either later today or tomorrow).
|
||||
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
|
||||
let currentTimePeriod = TimeOfDay.current(for: currentDate)
|
||||
|
||||
// 1. Try to find a ritual later TODAY.
|
||||
// 1. Try to find a ritual later TODAY
|
||||
let laterToday = rituals.filter { ritual in
|
||||
guard ritual.activeArc(on: currentDate) != nil else { return false }
|
||||
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: currentDate) }) else { return false }
|
||||
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||
}
|
||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||
.first
|
||||
|
||||
if let laterToday {
|
||||
return (laterToday, false)
|
||||
}
|
||||
|
||||
// 2. Try to find a ritual TOMORROW.
|
||||
if let laterToday { return laterToday }
|
||||
|
||||
// 2. Try to find a ritual TOMORROW
|
||||
let tomorrowDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate)
|
||||
let tomorrowRitual = rituals.filter { ritual in
|
||||
guard ritual.activeArc(on: tomorrowDate) != nil else { return false }
|
||||
return rituals.filter { ritual in
|
||||
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: tomorrowDate) }) else { return false }
|
||||
return ritual.timeOfDay != .anytime
|
||||
}
|
||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||
.first
|
||||
|
||||
if let tomorrowRitual {
|
||||
return (tomorrowRitual, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Finds the next upcoming ritual (either later today or tomorrow).
|
||||
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
|
||||
nextUpcomingRitualContext(from: rituals, currentDate: currentDate, calendar: calendar)?.ritual
|
||||
}
|
||||
|
||||
private static func isPerfectDay(_ date: Date, rituals: [Ritual], calendar: Calendar) -> Bool {
|
||||
let dayID = dayIdentifier(for: date, calendar: calendar)
|
||||
var activeHabits: [ArcHabit] = []
|
||||
|
||||
for ritual in rituals {
|
||||
if let arc = ritual.arc(for: date) {
|
||||
activeHabits.append(contentsOf: arc.habits ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
guard !activeHabits.isEmpty else { return false }
|
||||
return activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,7 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import SwiftData
|
||||
import Foundation
|
||||
@testable import Rituals
|
||||
@testable import Andromida
|
||||
|
||||
struct AndromidaTests {
|
||||
|
||||
@ -16,51 +14,4 @@ struct AndromidaTests {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func modelContextCanInsertBareRitual() throws {
|
||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||
let config = ModelConfiguration(
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: true,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
let context = container.mainContext
|
||||
|
||||
let ritual = Ritual(title: "Bare", theme: "Test", arcs: [])
|
||||
context.insert(ritual)
|
||||
try context.save()
|
||||
|
||||
let rituals = try context.fetch(FetchDescriptor<Ritual>())
|
||||
#expect(rituals.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func storeCanCreateQuickRitual() throws {
|
||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||
let config = ModelConfiguration(
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: true,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
let store = RitualStore(
|
||||
modelContext: container.mainContext,
|
||||
seedService: EmptySeedService(),
|
||||
settingsStore: TestFeedbackSettings()
|
||||
)
|
||||
|
||||
store.createQuickRitual()
|
||||
#expect(store.rituals.count == 1)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct EmptySeedService: RitualSeedProviding {
|
||||
func makeSeedRituals(startDate: Date) -> [Ritual] { [] }
|
||||
}
|
||||
|
||||
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
|
||||
var hapticsEnabled: Bool = false
|
||||
var soundEnabled: Bool = false
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
@testable import Rituals
|
||||
@testable import Andromida
|
||||
|
||||
@Suite(.serialized)
|
||||
struct RitualStoreTests {
|
||||
@MainActor
|
||||
@Test func quickRitualStartsIncomplete() throws {
|
||||
@ -32,20 +31,11 @@ struct RitualStoreTests {
|
||||
let store = makeStore()
|
||||
store.createQuickRitual()
|
||||
|
||||
guard let ritual = store.activeRitual,
|
||||
let habit = ritual.habits.first,
|
||||
let arc = ritual.currentArc else {
|
||||
guard let habit = store.activeRitual?.habits.first else {
|
||||
throw TestError.missingHabit
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
let completions = store.habitCompletions(for: yesterday)
|
||||
@ -63,7 +53,7 @@ struct RitualStoreTests {
|
||||
throw TestError.missingHabit
|
||||
}
|
||||
|
||||
#expect((ritual.arcs?.count ?? 0) == 1)
|
||||
#expect(ritual.arcs.count == 1)
|
||||
#expect(ritual.currentArc?.arcNumber == 1)
|
||||
|
||||
// End the current arc
|
||||
@ -74,350 +64,16 @@ struct RitualStoreTests {
|
||||
// Renew the arc
|
||||
store.renewArc(for: ritual, durationDays: 30, copyHabits: true)
|
||||
|
||||
#expect((ritual.arcs?.count ?? 0) == 2)
|
||||
#expect(ritual.arcs.count == 2)
|
||||
#expect(ritual.currentArc?.arcNumber == 2)
|
||||
#expect((ritual.currentArc?.habits?.count ?? 0) == 3)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func currentStreakCountsOnlyPerfectDays() throws {
|
||||
let store = makeStore()
|
||||
store.createQuickRitual()
|
||||
|
||||
guard let ritual = store.activeRitual,
|
||||
let arc = ritual.currentArc else {
|
||||
throw TestError.missingRitual
|
||||
}
|
||||
|
||||
let habits = ritual.habits
|
||||
guard habits.count >= 2 else {
|
||||
throw TestError.missingHabit
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let today = Date()
|
||||
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.
|
||||
for habit in habits {
|
||||
store.toggleHabitCompletion(habit, date: yesterday)
|
||||
}
|
||||
|
||||
// Today is partial: complete only one habit.
|
||||
store.toggleHabitCompletion(habits[0], date: today)
|
||||
|
||||
#expect(store.currentStreak() == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func allRitualCompletionRateIncludesEndedArcsForHistoricalDates() throws {
|
||||
let store = makeStore()
|
||||
store.createQuickRitual()
|
||||
|
||||
guard let ritual = store.activeRitual,
|
||||
let habit = ritual.habits.first,
|
||||
let arcStart = ritual.currentArc?.startDate else {
|
||||
throw TestError.missingHabit
|
||||
}
|
||||
|
||||
store.toggleHabitCompletion(habit, date: arcStart)
|
||||
store.endArc(for: ritual)
|
||||
|
||||
let rate = store.completionRate(for: arcStart, ritual: nil)
|
||||
#expect(rate > 0.0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func completedActiveArcStillTriggersRenewalPrompt() throws {
|
||||
let store = makeStore()
|
||||
store.createQuickRitual()
|
||||
|
||||
guard let ritual = store.activeRitual,
|
||||
let arc = ritual.currentArc else {
|
||||
throw TestError.missingRitual
|
||||
}
|
||||
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
|
||||
arc.endDate = yesterday
|
||||
|
||||
#expect(ritual.hasActiveArc == false)
|
||||
|
||||
store.checkForCompletedArcs()
|
||||
#expect(store.ritualNeedingRenewal?.id == ritual.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func currentArcUsesMostRecentActiveArc() throws {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let oldStart = calendar.date(byAdding: .day, value: -20, to: today) ?? today
|
||||
let oldEnd = calendar.date(byAdding: .day, value: -5, to: today) ?? today
|
||||
let currentStart = calendar.date(byAdding: .day, value: -2, to: today) ?? today
|
||||
let currentEnd = calendar.date(byAdding: .day, value: 10, to: today) ?? today
|
||||
|
||||
let oldArc = RitualArc(startDate: oldStart, endDate: oldEnd, arcNumber: 1, isActive: true)
|
||||
let currentArc = RitualArc(startDate: currentStart, endDate: currentEnd, arcNumber: 2, isActive: true)
|
||||
let ritual = Ritual(title: "Test", theme: "Theme", arcs: [oldArc, currentArc])
|
||||
|
||||
#expect(ritual.currentArc?.arcNumber == 2)
|
||||
#expect(ritual.hasActiveArc == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func completedArcCountIncludesExpiredActiveArcs() throws {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let start = calendar.date(byAdding: .day, value: -10, to: today) ?? today
|
||||
let end = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||
|
||||
let expiredButActiveArc = RitualArc(startDate: start, endDate: end, arcNumber: 1, isActive: true)
|
||||
let ritual = Ritual(title: "Expired", theme: "Theme", arcs: [expiredButActiveArc])
|
||||
|
||||
#expect(ritual.completedArcCount == 1)
|
||||
#expect(Calendar.current.isDate(ritual.lastCompletedDate ?? .distantPast, inSameDayAs: end))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func nextUpcomingRitualContextIdentifiesTomorrowForFutureArc() throws {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today
|
||||
let nextWeek = calendar.date(byAdding: .day, value: 7, to: tomorrow) ?? tomorrow
|
||||
let middayToday = calendar.date(byAdding: .hour, value: 12, to: today) ?? today
|
||||
|
||||
let tomorrowArc = RitualArc(startDate: tomorrow, endDate: nextWeek, arcNumber: 1, isActive: true)
|
||||
let eveningRitual = Ritual(
|
||||
title: "Evening Wind Down",
|
||||
theme: "Test",
|
||||
timeOfDay: .evening,
|
||||
arcs: [tomorrowArc]
|
||||
)
|
||||
|
||||
let context = RitualAnalytics.nextUpcomingRitualContext(from: [eveningRitual], currentDate: middayToday)
|
||||
#expect(context?.ritual.id == eveningRitual.id)
|
||||
#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)
|
||||
}
|
||||
}
|
||||
#expect(ritual.currentArc?.habits.count == 3)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
|
||||
private func makeStore() -> RitualStore {
|
||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||
let configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: true,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
let container: ModelContainer
|
||||
do {
|
||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
@ -425,12 +81,7 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
|
||||
fatalError("Test container failed: \(error)")
|
||||
}
|
||||
|
||||
return RitualStore(
|
||||
modelContext: container.mainContext,
|
||||
seedService: EmptySeedService(),
|
||||
settingsStore: TestFeedbackSettings(),
|
||||
now: now
|
||||
)
|
||||
return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService(), settingsStore: SettingsStore())
|
||||
}
|
||||
|
||||
private struct EmptySeedService: RitualSeedProviding {
|
||||
@ -439,12 +90,6 @@ private struct EmptySeedService: RitualSeedProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
|
||||
var hapticsEnabled: Bool = false
|
||||
var soundEnabled: Bool = false
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case missingHabit
|
||||
case missingRitual
|
||||
}
|
||||
|
||||
@ -10,190 +10,32 @@ import XCTest
|
||||
final class AndromidaUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testOnboardingFlowCompletes() throws {
|
||||
let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
|
||||
onboardingApp.launch()
|
||||
|
||||
let getStarted = onboardingApp.buttons["onboarding.getStarted"]
|
||||
XCTAssertTrue(getStarted.waitForExistence(timeout: 8))
|
||||
|
||||
onboardingApp.terminate()
|
||||
|
||||
let completedApp = makeApp(resetDefaults: false, hasCompletedSetupWizard: true)
|
||||
completedApp.launch()
|
||||
|
||||
let ritualsTab = completedApp.tabBars.buttons["Rituals"]
|
||||
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testOnboardingHappyPath() throws {
|
||||
throw XCTSkip("Temporarily disabled due flaky onboarding card accessibility matching in simulator UI tests.")
|
||||
|
||||
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.getStarted"].tap()
|
||||
|
||||
tapFirstMatchingElement(
|
||||
app: app,
|
||||
identifierPrefix: "onboarding.goal."
|
||||
)
|
||||
XCTAssertTrue(app.buttons["onboarding.goalContinue"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.goalContinue"].tap()
|
||||
|
||||
tapFirstMatchingElement(
|
||||
app: app,
|
||||
identifierPrefix: "onboarding.time."
|
||||
)
|
||||
XCTAssertTrue(app.buttons["onboarding.timeContinue"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.timeContinue"].tap()
|
||||
|
||||
if app.buttons["onboarding.startRitual"].waitForExistence(timeout: 8) {
|
||||
app.buttons["onboarding.startRitual"].tap()
|
||||
} else {
|
||||
XCTAssertTrue(app.buttons["onboarding.skipRitual"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.skipRitual"].tap()
|
||||
}
|
||||
|
||||
if app.buttons["onboarding.firstCheckInSkip"].waitForExistence(timeout: 8) {
|
||||
app.buttons["onboarding.firstCheckInSkip"].tap()
|
||||
} else {
|
||||
XCTAssertTrue(app.buttons["onboarding.firstCheckInContinueToRituals"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.firstCheckInContinueToRituals"].tap()
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.buttons["onboarding.notificationsMaybeLater"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.notificationsMaybeLater"].tap()
|
||||
|
||||
XCTAssertTrue(app.buttons["onboarding.letsGo"].waitForExistence(timeout: 8))
|
||||
app.buttons["onboarding.letsGo"].tap()
|
||||
|
||||
XCTAssertTrue(app.tabBars.buttons["Rituals"].waitForExistence(timeout: 8))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCreateRitualFromRitualsTab() throws {
|
||||
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
|
||||
app.launch()
|
||||
|
||||
let ritualsTab = app.tabBars.buttons["Rituals"]
|
||||
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
|
||||
ritualsTab.tap()
|
||||
|
||||
let addMenu = app.buttons["rituals.addMenu"]
|
||||
XCTAssertTrue(addMenu.waitForExistence(timeout: 8))
|
||||
addMenu.tap()
|
||||
|
||||
let createNew = app.buttons["rituals.createNew"]
|
||||
XCTAssertTrue(createNew.waitForExistence(timeout: 8))
|
||||
createNew.tap()
|
||||
|
||||
let titleField = app.textFields["ritualEditor.titleField"]
|
||||
XCTAssertTrue(titleField.waitForExistence(timeout: 8))
|
||||
titleField.tap()
|
||||
titleField.typeText("UI Test Ritual")
|
||||
|
||||
let themeField = app.textFields["ritualEditor.themeField"]
|
||||
XCTAssertTrue(themeField.waitForExistence(timeout: 8))
|
||||
themeField.tap()
|
||||
themeField.typeText("Daily focus")
|
||||
|
||||
let newHabitField = app.textFields["ritualEditor.newHabitField"]
|
||||
XCTAssertTrue(newHabitField.waitForExistence(timeout: 8))
|
||||
newHabitField.tap()
|
||||
newHabitField.typeText("Stretch")
|
||||
|
||||
let addHabitButton = app.buttons["ritualEditor.addHabitButton"]
|
||||
XCTAssertTrue(addHabitButton.waitForExistence(timeout: 8))
|
||||
addHabitButton.tap()
|
||||
|
||||
let saveButton = app.buttons["ritualEditor.saveButton"]
|
||||
XCTAssertTrue(saveButton.waitForExistence(timeout: 8))
|
||||
saveButton.tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["UI Test Ritual"].waitForExistence(timeout: 8))
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
app.launch()
|
||||
app.terminate()
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["UITEST_MODE"] = "1"
|
||||
if resetDefaults {
|
||||
app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1"
|
||||
}
|
||||
app.launchEnvironment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] = hasCompletedSetupWizard ? "1" : "0"
|
||||
return app
|
||||
}
|
||||
|
||||
private func tapFirstAvailableElement(
|
||||
app: XCUIApplication,
|
||||
identifiers: [String],
|
||||
fallbackLabels: [String],
|
||||
timeout: TimeInterval = 8
|
||||
) {
|
||||
for identifier in identifiers {
|
||||
let candidates = [
|
||||
app.buttons[identifier],
|
||||
app.otherElements[identifier],
|
||||
app.staticTexts[identifier],
|
||||
app.descendants(matching: .any)[identifier]
|
||||
]
|
||||
for element in candidates where element.waitForExistence(timeout: 1) {
|
||||
element.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for label in fallbackLabels {
|
||||
let candidates = [
|
||||
app.buttons[label],
|
||||
app.otherElements[label],
|
||||
app.staticTexts[label]
|
||||
]
|
||||
for element in candidates where element.waitForExistence(timeout: timeout) {
|
||||
element.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
XCTFail("Unable to locate tap target. identifiers=\(identifiers), labels=\(fallbackLabels)")
|
||||
}
|
||||
|
||||
private func tapFirstMatchingElement(
|
||||
app: XCUIApplication,
|
||||
identifierPrefix: String,
|
||||
timeout: TimeInterval = 8
|
||||
) {
|
||||
let predicate = NSPredicate(format: "identifier BEGINSWITH %@", identifierPrefix)
|
||||
let queries = [
|
||||
app.buttons.matching(predicate),
|
||||
app.otherElements.matching(predicate),
|
||||
app.staticTexts.matching(predicate),
|
||||
app.descendants(matching: .any).matching(predicate)
|
||||
]
|
||||
|
||||
for query in queries {
|
||||
let candidate = query.firstMatch
|
||||
if candidate.waitForExistence(timeout: timeout) {
|
||||
candidate.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
XCTFail("Unable to locate element with identifier prefix: \(identifierPrefix)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import XCTest
|
||||
final class AndromidaUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
false
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
|
||||
@ -107,7 +107,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
||||
|
||||
// Filter rituals for the target time of day
|
||||
let activeRituals = rituals.filter { ritual in
|
||||
guard ritual.activeArc(on: today) != nil else {
|
||||
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
|
||||
return false
|
||||
}
|
||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
||||
@ -121,7 +121,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
||||
|
||||
var visibleHabits: [HabitEntry] = []
|
||||
for ritual in activeRituals {
|
||||
if let arc = ritual.activeArc(on: today) {
|
||||
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
|
||||
// Sort habits within each ritual by their sortIndex
|
||||
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||
for habit in sortedHabits {
|
||||
@ -147,20 +147,21 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
||||
|
||||
// Next ritual info
|
||||
var nextRitualString: String? = nil
|
||||
if let nextContext = RitualAnalytics.nextUpcomingRitualContext(from: rituals, currentDate: targetDate) {
|
||||
if nextContext.isTomorrow {
|
||||
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
|
||||
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
|
||||
if isTomorrow {
|
||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||
nextRitualString = String.localizedStringWithFormat(
|
||||
format,
|
||||
nextContext.ritual.timeOfDay.displayName,
|
||||
nextContext.ritual.timeOfDay.timeRange
|
||||
nextRitual.timeOfDay.displayName,
|
||||
nextRitual.timeOfDay.timeRange
|
||||
)
|
||||
} else {
|
||||
let format = String(localized: "Next ritual: %@ (%@)")
|
||||
nextRitualString = String.localizedStringWithFormat(
|
||||
format,
|
||||
nextContext.ritual.timeOfDay.displayName,
|
||||
nextContext.ritual.timeOfDay.timeRange
|
||||
nextRitual.timeOfDay.displayName,
|
||||
nextRitual.timeOfDay.timeRange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
14
PRD.md
14
PRD.md
@ -113,7 +113,6 @@ Historical view of past completions and performance.
|
||||
| FR-HISTORY-04 | Support tap on any day to open detail sheet |
|
||||
| FR-HISTORY-05 | Detail sheet shows: progress ring with percentage, comparison to weekly average, streak context, motivational message, grouped habit list by ritual |
|
||||
| FR-HISTORY-06 | Support adaptive 2-column grid layout on iPad and landscape |
|
||||
| FR-HISTORY-07 | In "All" filter mode, calculations must include completed and archived arcs (not only currently active arcs) |
|
||||
|
||||
### 3.4 Insights Tab
|
||||
|
||||
@ -123,7 +122,7 @@ Analytics and trend visualization.
|
||||
|-------------|-------------|
|
||||
| FR-INSIGHTS-01 | Display 8 tappable insight cards with full-screen detail sheets |
|
||||
| FR-INSIGHTS-02 | **Active Rituals**: Count with per-ritual breakdown |
|
||||
| FR-INSIGHTS-03 | **Streak**: Current and longest perfect-day streak tracking (100% completion days) |
|
||||
| FR-INSIGHTS-03 | **Streak**: Current and longest streak tracking |
|
||||
| FR-INSIGHTS-04 | **Habits Today**: Completed count with per-ritual breakdown |
|
||||
| FR-INSIGHTS-05 | **Completion**: Today's percentage with 7-day trend chart |
|
||||
| FR-INSIGHTS-06 | **Days Active**: Total active days with detailed breakdown |
|
||||
@ -164,7 +163,6 @@ Home screen widget for at-a-glance progress.
|
||||
| FR-WIDGET-07 | Support App Intents for widget configuration |
|
||||
| FR-WIDGET-08 | Update widget content every 15 minutes |
|
||||
| FR-WIDGET-09 | Use App Group shared container for SwiftData access |
|
||||
| FR-WIDGET-10 | "Next ritual" label must correctly distinguish "later today" vs "tomorrow" based on target timeline date |
|
||||
|
||||
### 3.7 Onboarding
|
||||
|
||||
@ -262,7 +260,6 @@ URL scheme support for navigation.
|
||||
| TR-DATA-02 | Use NSUbiquitousKeyValueStore (via Bedrock CloudSyncManager) for settings sync |
|
||||
| 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-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
||||
|
||||
### 5.4 Third-Party Dependencies
|
||||
|
||||
@ -363,7 +360,7 @@ Analytics display card.
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| Active | Active ritual count with breakdown |
|
||||
| Streak | Current and longest perfect-day streak (100% completion days) |
|
||||
| Streak | Current and longest streak |
|
||||
| HabitsToday | Today's completed habits |
|
||||
| Completion | Today's percentage with trend |
|
||||
| DaysActive | Total active days |
|
||||
@ -591,11 +588,7 @@ Andromida/
|
||||
|-------------|-------------|
|
||||
| Unit tests in `AndromidaTests/` covering store logic and analytics |
|
||||
| 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-test harness should run app launches with deterministic flags (`UITEST_MODE`, optional reset/onboarding overrides) and in-memory SwiftData |
|
||||
| UI launch coverage should prioritize stable smoke validation over exhaustive simulator configuration matrices |
|
||||
| UI coverage should include onboarding state transition validation and ritual creation flow from the Rituals tab |
|
||||
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'` |
|
||||
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
|
||||
|
||||
---
|
||||
|
||||
@ -605,4 +598,3 @@ Andromida/
|
||||
|---------|------|-------------|
|
||||
| 1.0 | February 2026 | Initial PRD based on implemented features |
|
||||
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
|
||||
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |
|
||||
|
||||
20
README.md
20
README.md
@ -14,7 +14,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
||||
### Today Tab
|
||||
- Focus ritual cards with progress rings
|
||||
- Tap-to-complete habit check-ins with haptic/sound feedback
|
||||
- Time-of-day filtering (morning, midday, afternoon, evening, night, anytime)
|
||||
- Time-of-day filtering (morning/evening/anytime rituals)
|
||||
- Smart empty states (distinguishes "no rituals" from "no rituals for this time")
|
||||
- Fresh install starts clean (no pre-seeded rituals)
|
||||
|
||||
@ -40,14 +40,13 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
||||
- Habit icon picker with 100+ icons organized by category
|
||||
- Custom category input (beyond preset categories)
|
||||
- Flexible duration: slider (7-365 days) + quick presets + custom input
|
||||
- Time-of-day scheduling (morning, midday, afternoon, evening, night, anytime)
|
||||
- Time-of-day scheduling (morning, evening, anytime)
|
||||
- Drag-to-reorder habits
|
||||
|
||||
### History Tab
|
||||
- Scrollable month calendar grid
|
||||
- Daily progress rings with color coding (green=100%, accent=50%+, gray=<50%)
|
||||
- Filter by ritual using horizontal pill picker
|
||||
- "All" mode includes historical data from completed and archived arcs
|
||||
- Tap any day for detail sheet showing:
|
||||
- Progress ring with percentage
|
||||
- Comparison to weekly average
|
||||
@ -58,7 +57,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
||||
### Insights Tab
|
||||
- Tappable insight cards with full-screen detail sheets
|
||||
- **Active Rituals**: Count with per-ritual breakdown
|
||||
- **Streak**: Current and longest perfect-day streak tracking (100% completion days)
|
||||
- **Streak**: Current and longest streak tracking
|
||||
- **Habits Today**: Completed count with per-ritual breakdown
|
||||
- **Completion**: Today's percentage with 7-day trend chart
|
||||
- **Days Active**: Total active days with detailed breakdown (first check-in, most recent, per-ritual counts)
|
||||
@ -74,10 +73,6 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
||||
- iCloud settings sync
|
||||
- Debug tools: reset onboarding, app icon generation, branding preview
|
||||
|
||||
### Widget Behavior Notes
|
||||
- "Next ritual" messaging explicitly distinguishes later-today vs tomorrow scheduling.
|
||||
- Timeline calculations use in-progress arc detection for the target date.
|
||||
|
||||
### Onboarding
|
||||
- Setup wizard on first launch (goal, time, ritual preview, first check-in)
|
||||
- Ends with a quick orientation to Today, Rituals, and Insights
|
||||
@ -182,12 +177,7 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
|
||||
|
||||
- Unit tests in `AndromidaTests/`
|
||||
- Run via Xcode Test navigator or:
|
||||
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'`
|
||||
- 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.
|
||||
- `AndromidaUITests` launch with `UITEST_MODE=1`, forcing in-memory SwiftData and optional launch-state overrides (`UITEST_RESET_USER_DEFAULTS`, `UITEST_HAS_COMPLETED_SETUP_WIZARD`) for deterministic UI runs.
|
||||
- UI coverage includes stable onboarding-state transition validation and ritual creation from the Rituals tab.
|
||||
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'`
|
||||
|
||||
## Notes
|
||||
|
||||
@ -195,5 +185,3 @@ 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.
|
||||
- App icon generation is available in DEBUG builds from Settings.
|
||||
- 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