updates from codex

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

View File

@ -27,12 +27,15 @@ struct AndromidaApp: App {
// Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
// Use App Group for shared container between app and widget
// Use App Group for shared container between app and widget.
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
let configuration = ModelConfiguration(
schema: schema,
url: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite"),
cloudKitDatabase: .private(AppIdentifiers.cloudKitContainerIdentifier)
url: storeURL,
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
)
let container: ModelContainer

View File

@ -1716,6 +1716,18 @@
"comment" : "A label displayed above a section that lets the user set the duration of the next ritual arc.",
"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" : {

View File

@ -172,22 +172,22 @@ final class Ritual {
/// Habits from the current arc sorted by sortIndex (empty if no active arc).
var habits: [ArcHabit] {
(currentArc?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
(activeArc(on: Date())?.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
}
/// Start date of the current arc.
var startDate: Date {
currentArc?.startDate ?? Date()
activeArc(on: Date())?.startDate ?? Date()
}
/// Duration of the current arc in days.
var durationDays: Int {
currentArc?.durationDays ?? defaultDurationDays
activeArc(on: Date())?.durationDays ?? defaultDurationDays
}
/// End date of the current arc.
var endDate: Date {
currentArc?.endDate ?? Date()
activeArc(on: Date())?.endDate ?? Date()
}
// MARK: - Arc Queries

View File

@ -0,0 +1,7 @@
import Foundation
/// Minimal settings surface RitualStore needs for check-in feedback behavior.
protocol RitualFeedbackSettingsProviding {
var hapticsEnabled: Bool { get }
var soundEnabled: Bool { get }
}

View File

@ -8,10 +8,15 @@ import WidgetKit
@MainActor
@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: SettingsStore
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
@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?
@ -35,7 +40,7 @@ final class RitualStore: RitualStoreProviding {
var ritualNeedingRenewal: Ritual?
/// The current time of day, updated periodically. Observable for UI refresh.
private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current()
private(set) var currentTimeOfDay: TimeOfDay = .anytime
/// Debug override for time of day (nil = use real time)
var debugTimeOfDayOverride: TimeOfDay? {
@ -53,13 +58,16 @@ final class RitualStore: RitualStoreProviding {
init(
modelContext: ModelContext,
seedService: RitualSeedProviding,
settingsStore: SettingsStore,
calendar: Calendar = .current
settingsStore: any RitualFeedbackSettingsProviding,
calendar: Calendar = .current,
now: @escaping () -> Date = Date.init
) {
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
@ -67,9 +75,15 @@ final class RitualStore: RitualStoreProviding {
displayFormatter.calendar = calendar
displayFormatter.dateStyle = .full
displayFormatter.timeStyle = .none
self.currentTimeOfDay = TimeOfDay.current(for: now())
runDataIntegrityMigrationIfNeeded()
loadRitualsIfNeeded()
observeRemoteChanges()
}
private func now() -> Date {
nowProvider()
}
deinit {
if let observer = remoteChangeObserver {
@ -99,7 +113,7 @@ final class RitualStore: RitualStoreProviding {
}
var todayDisplayString: String {
displayFormatter.string(from: Date())
displayFormatter.string(from: now())
}
var activeRitualProgress: Double {
@ -120,7 +134,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()
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
if newTimeOfDay != currentTimeOfDay {
currentTimeOfDay = newTimeOfDay
return true
@ -130,16 +144,16 @@ final class RitualStore: RitualStoreProviding {
/// Returns the effective time of day (considering debug override).
func effectiveTimeOfDay() -> TimeOfDay {
debugTimeOfDayOverride ?? TimeOfDay.current()
debugTimeOfDayOverride ?? TimeOfDay.current(for: now())
}
/// Refreshes rituals if the last refresh was beyond the minimum interval.
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
let now = Date()
if let lastRefreshDate, now.timeIntervalSince(lastRefreshDate) < minimumInterval {
let currentDate = now()
if let lastRefreshDate, currentDate.timeIntervalSince(lastRefreshDate) < minimumInterval {
return
}
lastRefreshDate = now
lastRefreshDate = currentDate
refresh()
}
@ -155,15 +169,15 @@ final class RitualStore: RitualStoreProviding {
}
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
let dayID = dayIdentifier(for: Date())
let dayID = dayIdentifier(for: now())
return habit.completedDayIDs.contains(dayID)
}
func toggleHabitCompletion(_ habit: ArcHabit) {
toggleHabitCompletion(habit, date: Date())
toggleHabitCompletion(habit, date: now())
}
func toggleHabitCompletion(_ habit: ArcHabit, date: Date = Date()) {
func toggleHabitCompletion(_ habit: ArcHabit, date: Date) {
let dayID = dayIdentifier(for: date)
let wasCompleted = habit.completedDayIDs.contains(dayID)
@ -183,8 +197,9 @@ final class RitualStore: RitualStoreProviding {
}
func ritualDayIndex(for ritual: Ritual) -> Int {
guard let arc = ritual.activeArc(on: Date()) else { return 0 }
return arc.dayIndex(for: Date())
let currentDate = now()
guard let arc = ritual.activeArc(on: currentDate) else { return 0 }
return arc.dayIndex(for: currentDate)
}
func ritualDayLabel(for ritual: Ritual) -> String {
@ -213,7 +228,7 @@ final class RitualStore: RitualStoreProviding {
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
func ritualsForToday() -> [Ritual] {
let today = Date()
let today = now()
let timeOfDay = effectiveTimeOfDay()
return currentRituals.filter { ritual in
@ -256,7 +271,7 @@ final class RitualStore: RitualStoreProviding {
/// Checks if a ritual's current arc has completed (past end date).
func isArcCompleted(_ ritual: Ritual) -> Bool {
guard let arc = ritual.currentArc else { return false }
return arc.isCompleted(asOf: Date(), calendar: calendar)
return arc.isCompleted(asOf: now(), calendar: calendar)
}
/// Checks for rituals that need renewal and triggers the prompt.
@ -292,7 +307,7 @@ final class RitualStore: RitualStoreProviding {
}
let newArc = RitualArc(
startDate: Date(),
startDate: now(),
durationDays: duration,
arcNumber: newArcNumber,
isActive: true,
@ -342,7 +357,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)
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar, currentDate: now())
}
/// Calculates the longest streak of consecutive perfect days
@ -378,7 +393,7 @@ final class RitualStore: RitualStoreProviding {
/// Returns completion data for the last 7 days for a trend chart
func weeklyTrendData() -> [TrendDataPoint] {
let today = calendar.startOfDay(for: Date())
let today = calendar.startOfDay(for: now())
let shortWeekdayFormatter = DateFormatter()
shortWeekdayFormatter.calendar = calendar
shortWeekdayFormatter.dateFormat = "EEE"
@ -476,12 +491,13 @@ final class RitualStore: RitualStoreProviding {
}
private func computeInsightCards() -> [InsightCard] {
let currentDate = now()
let inProgressRituals = currentRituals.filter { ritual in
ritual.activeArc(on: Date()) != nil
ritual.activeArc(on: currentDate) != nil
}
// Only count habits from active arcs for today's stats
let activeHabitsToday = habitsInProgress(on: Date())
let activeHabitsToday = habitsInProgress(on: currentDate)
let totalHabits = activeHabitsToday.count
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
@ -528,11 +544,11 @@ final class RitualStore: RitualStoreProviding {
let bestRitualInfo: (title: String, rate: Int)? = {
var best: (title: String, rate: Int)?
for ritual in inProgressRituals {
guard let arc = ritual.activeArc(on: Date()) else { continue }
guard let arc = ritual.activeArc(on: currentDate) else { continue }
let habits = arc.habits ?? []
guard !habits.isEmpty else { continue }
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = habits.count * arc.dayIndex(for: Date())
let possibleCheckIns = habits.count * arc.dayIndex(for: currentDate)
guard possibleCheckIns > 0 else { continue }
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
if best.map({ rate > $0.rate }) ?? true {
@ -676,25 +692,68 @@ 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)
]
let arc = RitualArc(
startDate: Date(),
durationDays: defaultDuration,
arcNumber: 1,
isActive: true,
habits: habits
)
let nextSortIndex = rituals.count
let ritual = Ritual(
_ = createRitualWithInitialArc(
title: String(localized: "Custom Ritual"),
theme: String(localized: "Your next chapter"),
defaultDurationDays: defaultDuration,
notes: String(localized: "A fresh ritual created from your focus today."),
sortIndex: nextSortIndex,
arcs: [arc]
timeOfDay: .anytime,
iconName: "sparkles",
category: "",
sortIndex: rituals.count,
durationDays: defaultDuration,
habits: habits
)
modelContext.insert(ritual)
saveContext()
}
private func createRitualWithInitialArc(
title: String,
theme: String,
defaultDurationDays: Int,
notes: String,
timeOfDay: TimeOfDay,
iconName: String,
category: String,
sortIndex: Int,
durationDays: Int,
habits: [ArcHabit]
) -> Ritual {
let ritual = Ritual(
title: title,
theme: theme,
defaultDurationDays: defaultDurationDays,
notes: notes,
timeOfDay: timeOfDay,
iconName: iconName,
category: category,
sortIndex: sortIndex,
arcs: []
)
modelContext.insert(ritual)
let indexedHabits = habits.enumerated().map { index, habit in
habit.sortIndex = index
return habit
}
for habit in indexedHabits {
modelContext.insert(habit)
}
let arc = RitualArc(
startDate: now(),
durationDays: durationDays,
arcNumber: 1,
isActive: true,
habits: []
)
modelContext.insert(arc)
arc.habits = indexedHabits
ritual.arcs = [arc]
return ritual
}
/// Creates a new ritual with the given properties
func createRitual(
@ -707,20 +766,7 @@ final class RitualStore: RitualStoreProviding {
category: String = "",
habits: [ArcHabit] = []
) {
// Assign sortIndex to habits if not already set
let indexedHabits = habits.enumerated().map { index, habit in
habit.sortIndex = index
return habit
}
let arc = RitualArc(
startDate: Date(),
durationDays: durationDays,
arcNumber: 1,
isActive: true,
habits: indexedHabits
)
let nextSortIndex = rituals.count
let ritual = Ritual(
_ = createRitualWithInitialArc(
title: title,
theme: theme,
defaultDurationDays: durationDays,
@ -728,10 +774,10 @@ final class RitualStore: RitualStoreProviding {
timeOfDay: timeOfDay,
iconName: iconName,
category: category,
sortIndex: nextSortIndex,
arcs: [arc]
sortIndex: rituals.count,
durationDays: durationDays,
habits: habits
)
modelContext.insert(ritual)
saveContext()
}
@ -759,15 +805,8 @@ final class RitualStore: RitualStoreProviding {
let habits = preset.habits.enumerated().map { index, habitPreset in
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
}
let arc = RitualArc(
startDate: Date(),
durationDays: preset.durationDays,
arcNumber: 1,
isActive: true,
habits: habits
)
let nextSortIndex = rituals.count
let ritual = Ritual(
let ritual = createRitualWithInitialArc(
title: preset.title,
theme: preset.theme,
defaultDurationDays: preset.durationDays,
@ -775,10 +814,10 @@ final class RitualStore: RitualStoreProviding {
timeOfDay: preset.timeOfDay,
iconName: preset.iconName,
category: preset.category,
sortIndex: nextSortIndex,
arcs: [arc]
sortIndex: rituals.count,
durationDays: preset.durationDays,
habits: habits
)
modelContext.insert(ritual)
saveContext()
return ritual
}
@ -803,7 +842,7 @@ final class RitualStore: RitualStoreProviding {
ritual.category = category
// Also update the current arc's end date if duration changed
if let currentArc = ritual.currentArc, currentArc.durationDays != durationDays {
if let currentArc = ritual.activeArc(on: now()), currentArc.durationDays != durationDays {
let newEndDate = calendar.date(byAdding: .day, value: durationDays - 1, to: currentArc.startDate) ?? currentArc.endDate
currentArc.endDate = newEndDate
}
@ -819,7 +858,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.currentArc else { return }
guard let arc = ritual.activeArc(on: now()) 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)
@ -831,7 +870,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.currentArc else { return }
guard let arc = ritual.activeArc(on: now()) else { return }
var habits = arc.habits ?? []
habits.removeAll { $0.id == habit.id }
arc.habits = habits
@ -845,6 +884,108 @@ 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>())
@ -871,8 +1012,9 @@ final class RitualStore: RitualStoreProviding {
}
private func updateDerivedData() {
let currentDate = now()
currentRituals = rituals
.filter { $0.hasActiveArc }
.filter { $0.activeArc(on: currentDate) != nil }
.sorted { lhs, rhs in
if lhs.timeOfDay != rhs.timeOfDay {
return lhs.timeOfDay < rhs.timeOfDay
@ -880,7 +1022,7 @@ final class RitualStore: RitualStoreProviding {
return lhs.sortIndex < rhs.sortIndex
}
pastRituals = rituals
.filter { !$0.hasActiveArc }
.filter { $0.activeArc(on: currentDate) == nil }
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
}
@ -935,6 +1077,8 @@ 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 {
@ -1069,8 +1213,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.currentArc else { return 0 }
let today = calendar.startOfDay(for: Date())
guard let arc = ritual.activeArc(on: now()) else { return 0 }
let today = calendar.startOfDay(for: now())
let endDate = calendar.startOfDay(for: arc.endDate)
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
@ -1079,12 +1223,12 @@ final class RitualStore: RitualStoreProviding {
/// Returns the current streak for a specific ritual's current arc.
func streakForRitual(_ ritual: Ritual) -> Int {
guard let arc = ritual.currentArc else { return 0 }
guard let arc = ritual.activeArc(on: now()) else { return 0 }
let habits = arc.habits ?? []
guard !habits.isEmpty else { return 0 }
var streak = 0
var checkDate = calendar.startOfDay(for: Date())
var checkDate = calendar.startOfDay(for: now())
while arc.contains(date: checkDate) {
let dayID = dayIdentifier(for: checkDate)
@ -1105,10 +1249,11 @@ final class RitualStore: RitualStoreProviding {
/// Returns nil if there's no previous arc to compare against.
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
guard let currentArc = ritual.activeArc(on: Date()) else { return nil }
let currentDate = now()
guard let currentArc = ritual.activeArc(on: currentDate) else { return nil }
// Find the previous arc (most recent completed arc)
let completedArcs = ritual.completedArcs(asOf: Date())
let completedArcs = ritual.completedArcs(asOf: currentDate)
.sorted { $0.arcNumber > $1.arcNumber }
guard let previousArc = completedArcs.first else { return nil }
@ -1266,7 +1411,7 @@ final class RitualStore: RitualStoreProviding {
/// Returns the week-over-week change in completion rate.
func weekOverWeekChange() -> Double {
let today = calendar.startOfDay(for: Date())
let today = calendar.startOfDay(for: now())
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
return 0
}
@ -1298,7 +1443,7 @@ final class RitualStore: RitualStoreProviding {
/// Returns the insight context for tips generation.
func insightContext() -> InsightContext {
let activeHabitsToday = habitsInProgress(on: Date())
let activeHabitsToday = habitsInProgress(on: now())
let totalHabits = activeHabitsToday.count
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
@ -1323,7 +1468,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: Date())
let today = calendar.startOfDay(for: now())
// Go back 6 months
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
@ -1406,13 +1551,13 @@ final class RitualStore: RitualStoreProviding {
func simulateArcCompletion() {
// Find the first ritual with an active arc
guard let ritual = currentRituals.first,
let arc = ritual.currentArc else {
let arc = ritual.activeArc(on: now()) 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: Date())) ?? Date()
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now())) ?? now()
arc.endDate = yesterday
// Also backdate the start date so the arc looks like it ran for a reasonable period

View File

@ -4,7 +4,7 @@ import Bedrock
@MainActor
@Observable
final class SettingsStore: CloudSyncable, ThemeProviding {
final class SettingsStore: CloudSyncable, ThemeProviding, RitualFeedbackSettingsProviding {
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
@ObservationIgnored private var cloudChangeObserver: NSObjectProtocol?
@ObservationIgnored private var isApplyingCloudUpdate = false

View File

@ -16,11 +16,12 @@ enum RitualAnalytics {
/// - 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) -> Int {
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: Date())
var checkDate = calendar.startOfDay(for: currentDate)
// If today isn't perfect, start from yesterday.
if !isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {

View File

@ -31,11 +31,20 @@ struct RitualStoreTests {
let store = makeStore()
store.createQuickRitual()
guard let habit = store.activeRitual?.habits.first else {
guard let ritual = store.activeRitual,
let habit = ritual.habits.first,
let arc = ritual.currentArc else {
throw TestError.missingHabit
}
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)
@ -74,7 +83,8 @@ struct RitualStoreTests {
let store = makeStore()
store.createQuickRitual()
guard let ritual = store.activeRitual else {
guard let ritual = store.activeRitual,
let arc = ritual.currentArc else {
throw TestError.missingRitual
}
@ -86,6 +96,14 @@ struct RitualStoreTests {
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 {
@ -186,20 +204,258 @@ struct RitualStoreTests {
#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)
}
}
}
}
@MainActor
private func makeStore() -> RitualStore {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
let container: ModelContainer
do {
container = try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Test container failed: \(error)")
}
private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
let container = SharedTestContainer.container
clearSharedTestContainer(container.mainContext)
return RitualStore(modelContext: container.mainContext, seedService: EmptySeedService(), settingsStore: SettingsStore())
return RitualStore(
modelContext: container.mainContext,
seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings(),
now: now
)
}
@MainActor
private enum SharedTestContainer {
static let container: ModelContainer = {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Test container failed: \(error)")
}
}()
}
@MainActor
private func clearSharedTestContainer(_ context: ModelContext) {
do {
for habit in try context.fetch(FetchDescriptor<ArcHabit>()) {
context.delete(habit)
}
for arc in try context.fetch(FetchDescriptor<RitualArc>()) {
context.delete(arc)
}
for ritual in try context.fetch(FetchDescriptor<Ritual>()) {
context.delete(ritual)
}
try context.save()
} catch {
fatalError("Failed to reset shared test container: \(error)")
}
}
private struct EmptySeedService: RitualSeedProviding {
@ -208,6 +464,11 @@ private struct EmptySeedService: RitualSeedProviding {
}
}
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
var hapticsEnabled: Bool = false
var soundEnabled: Bool = false
}
private enum TestError: Error {
case missingHabit
case missingRitual

View File

@ -10,7 +10,7 @@ import XCTest
final class AndromidaUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
false
}
override func setUpWithError() throws {

3
PRD.md
View File

@ -262,6 +262,7 @@ URL scheme support for navigation.
| TR-DATA-02 | Use NSUbiquitousKeyValueStore (via Bedrock CloudSyncManager) for settings sync |
| TR-DATA-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
@ -590,6 +591,8 @@ 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 launch coverage should prioritize stable smoke validation over exhaustive simulator configuration matrices |
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
---

View File

@ -183,6 +183,9 @@ 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'`
- XCTest runs disable SwiftData CloudKit mirroring in the host app to keep simulator tests deterministic.
- `RitualStoreTests` use a shared in-memory SwiftData container with per-test cleanup to avoid host-process container churn.
- `AndromidaUITestsLaunchTests` runs a single launch configuration to reduce flaky simulator timeouts.
## Notes
@ -190,3 +193,5 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
- The launch storyboard matches the branding primary color to avoid a white flash.
- 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.