From 28c02820687da3697da120d966c0587263b8096e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 8 Feb 2026 10:03:49 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/xcschemes/Andromida.xcscheme | 31 +++++ Andromida/App/Models/Ritual.swift | 28 +++- Andromida/App/Models/RitualArc.swift | 16 +++ Andromida/App/State/CategoryStore.swift | 6 +- Andromida/App/State/RitualStore.swift | 53 ++++---- Andromida/App/Views/History/HistoryView.swift | 5 - .../App/Views/Rituals/ArcDetailView.swift | 25 +--- .../App/Views/Rituals/RitualDetailView.swift | 9 +- .../TodayNoRitualsForTimeView.swift | 43 +++--- .../Shared/Services/RitualAnalytics.swift | 93 +++++++------ AndromidaTests/AndromidaTests.swift | 2 +- AndromidaTests/RitualStoreTests.swift | 127 +++++++++++++++++- .../Providers/AndromidaWidgetProvider.swift | 17 ++- PRD.md | 6 +- README.md | 11 +- 16 files changed, 328 insertions(+), 148 deletions(-) diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index 6d4cc78..00a2610 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -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)/Andromida.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Andromida"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rituals.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Rituals"; }; 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)/Andromida.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Andromida"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Rituals.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Rituals"; }; name = Release; }; diff --git a/Andromida.xcodeproj/xcshareddata/xcschemes/Andromida.xcscheme b/Andromida.xcodeproj/xcshareddata/xcschemes/Andromida.xcscheme index 54a2e7e..6c6e8ca 100644 --- a/Andromida.xcodeproj/xcshareddata/xcschemes/Andromida.xcscheme +++ b/Andromida.xcodeproj/xcshareddata/xcschemes/Andromida.xcscheme @@ -29,6 +29,37 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + + + + + + + + + $1.endDate } .first?.endDate } @@ -190,9 +192,23 @@ final class Ritual { // 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 ?? []).first { $0.contains(date: date) } + (arcs ?? []) + .filter { $0.contains(date: date) } + .max { $0.startDate < $1.startDate } } /// Returns all arcs that overlap with a date range. diff --git a/Andromida/App/Models/RitualArc.swift b/Andromida/App/Models/RitualArc.swift index d4e61b9..c453627 100644 --- a/Andromida/App/Models/RitualArc.swift +++ b/Andromida/App/Models/RitualArc.swift @@ -70,6 +70,22 @@ 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 { diff --git a/Andromida/App/State/CategoryStore.swift b/Andromida/App/State/CategoryStore.swift index 6d1b50a..653df20 100644 --- a/Andromida/App/State/CategoryStore.swift +++ b/Andromida/App/State/CategoryStore.swift @@ -21,7 +21,7 @@ final class CategoryStore { /// Get a category by name func category(named name: String) -> Category? { - categories.first { $0.name == name } + categories.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } } /// 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 category(named: trimmedName) == nil else { return } + guard isNameAvailable(trimmedName) 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.category(named: trimmedName) == nil { + if !trimmedName.isEmpty && self.isNameAvailable(trimmedName, excluding: categories[index]) { categories[index].name = trimmedName } } diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index ea2c721..f1b9ede 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -95,11 +95,7 @@ final class RitualStore: RitualStoreProviding { } var activeRitual: Ritual? { - // 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()) - } + currentRituals.first } var todayDisplayString: String { @@ -187,7 +183,7 @@ final class RitualStore: RitualStoreProviding { } func ritualDayIndex(for ritual: Ritual) -> Int { - guard let arc = ritual.currentArc else { return 0 } + guard let arc = ritual.activeArc(on: Date()) else { return 0 } return arc.dayIndex(for: Date()) } @@ -221,7 +217,7 @@ final class RitualStore: RitualStoreProviding { let timeOfDay = effectiveTimeOfDay() return currentRituals.filter { ritual in - guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else { + guard ritual.activeArc(on: today) != nil else { return false } return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay @@ -249,22 +245,24 @@ 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 } - let today = calendar.startOfDay(for: Date()) - let endDate = calendar.startOfDay(for: arc.endDate) - return today > endDate + return arc.isCompleted(asOf: Date(), calendar: calendar) } /// Checks for rituals that need renewal and triggers the prompt. func checkForCompletedArcs() { - for ritual in currentRituals { - if isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID) { - ritualNeedingRenewal = ritual - break - } + ritualNeedingRenewal = rituals.first { ritual in + isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID) } } @@ -478,8 +476,12 @@ final class RitualStore: RitualStoreProviding { } private func computeInsightCards() -> [InsightCard] { + let inProgressRituals = currentRituals.filter { ritual in + ritual.activeArc(on: Date()) != nil + } + // Only count habits from active arcs for today's stats - let activeHabitsToday = habitsActive(on: Date()) + let activeHabitsToday = habitsInProgress(on: Date()) let totalHabits = activeHabitsToday.count let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100) @@ -488,10 +490,10 @@ final class RitualStore: RitualStoreProviding { let daysActiveCount = datesWithActivity().count // Count rituals with active arcs - let activeRitualCount = currentRituals.count + let activeRitualCount = inProgressRituals.count // Build per-ritual progress breakdown - let habitsBreakdown = currentRituals.map { ritual in + let habitsBreakdown = inProgressRituals.map { ritual in let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count return BreakdownItem( label: ritual.title, @@ -525,15 +527,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 currentRituals { - guard let arc = ritual.currentArc else { continue } + for ritual in inProgressRituals { + guard let arc = ritual.activeArc(on: Date()) 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()) guard possibleCheckIns > 0 else { continue } let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) - if best == nil || rate > best!.rate { + if best.map({ rate > $0.rate }) ?? true { best = (ritual.title, rate) } } @@ -549,7 +551,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: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) } + breakdown: inProgressRituals.map { BreakdownItem(label: $0.title, value: $0.theme) } ), .streak: InsightCard( type: .streak, @@ -1103,11 +1105,10 @@ 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.currentArc else { return nil } + guard let currentArc = ritual.activeArc(on: Date()) else { return nil } // Find the previous arc (most recent completed arc) - let completedArcs = (ritual.arcs ?? []) - .filter { !$0.isActive } + let completedArcs = ritual.completedArcs(asOf: Date()) .sorted { $0.arcNumber > $1.arcNumber } guard let previousArc = completedArcs.first else { return nil } @@ -1297,7 +1298,7 @@ final class RitualStore: RitualStoreProviding { /// Returns the insight context for tips generation. func insightContext() -> InsightContext { - let activeHabitsToday = habitsActive(on: Date()) + let activeHabitsToday = habitsInProgress(on: Date()) let totalHabits = activeHabitsToday.count let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0 diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index e95cd24..199fb58 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -27,11 +27,6 @@ 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( diff --git a/Andromida/App/Views/Rituals/ArcDetailView.swift b/Andromida/App/Views/Rituals/ArcDetailView.swift index 0fb6eca..07bc4f9 100644 --- a/Andromida/App/Views/Rituals/ArcDetailView.swift +++ b/Andromida/App/Views/Rituals/ArcDetailView.swift @@ -26,11 +26,6 @@ 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( @@ -120,22 +115,6 @@ 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 ?? [] @@ -144,9 +123,7 @@ struct ArcDetailView: View { // Only return rate if date is within arc range guard arc.contains(date: date) else { return 0 } - let dayFormatter = DateFormatter() - dayFormatter.dateFormat = "yyyy-MM-dd" - let dayID = dayFormatter.string(from: date) + let dayID = RitualAnalytics.dayIdentifier(for: date) let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count return Double(completed) / Double(habits.count) diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index e33e790..844779c 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -20,11 +20,6 @@ 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) @@ -67,7 +62,7 @@ struct RitualDetailView: View { } private var completedArcs: [RitualArc] { - (ritual.arcs ?? []).filter { !$0.isActive }.sorted { $0.startDate > $1.startDate } + ritual.completedArcs(asOf: Date()).sorted { $0.startDate > $1.startDate } } private var arcComparisonInfo: (text: String, isAhead: Bool, isBehind: Bool)? { @@ -311,7 +306,7 @@ struct RitualDetailView: View { private var statusBadges: some View { HStack(spacing: Design.Spacing.medium) { // Current arc indicator - if let arc = ritual.currentArc { + if let arc = ritual.activeArc(on: Date()) { Text(String(localized: "Arc \(arc.arcNumber)")).styled(.captionEmphasis, emphasis: .custom(AppAccent.primary)) .padding(.horizontal, Design.Spacing.small) .padding(.vertical, Design.Spacing.xSmall) diff --git a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift index 3484685..fb2422a 100644 --- a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift +++ b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift @@ -6,19 +6,19 @@ struct TodayNoRitualsForTimeView: View { @Bindable var store: RitualStore private var currentTimePeriod: TimeOfDay { - TimeOfDay.current() + store.effectiveTimeOfDay() } private var nextRituals: [Ritual] { // Find rituals scheduled for later time periods TODAY store.currentRituals.filter { ritual in - guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false } + guard ritual.activeArc(on: Date()) != nil else { return false } return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod } } - private var nextRitualTomorrow: Ritual? { - RitualAnalytics.nextUpcomingRitual(from: store.currentRituals) + private var nextRitualContext: (ritual: Ritual, isTomorrow: Bool)? { + RitualAnalytics.nextUpcomingRitualContext(from: store.currentRituals) } var body: some View { @@ -71,17 +71,30 @@ struct TodayNoRitualsForTimeView: View { } } .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) + } 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) + } } // Motivational message diff --git a/Andromida/Shared/Services/RitualAnalytics.swift b/Andromida/Shared/Services/RitualAnalytics.swift index caf764e..e76c914 100644 --- a/Andromida/Shared/Services/RitualAnalytics.swift +++ b/Andromida/Shared/Services/RitualAnalytics.swift @@ -3,51 +3,36 @@ 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) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + 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) } - /// Calculates the current streak of consecutive days with activity. + /// Calculates the current streak of consecutive perfect days (100% completion). /// - Parameters: /// - rituals: The list of rituals to analyze. /// - calendar: The calendar to use for date calculations. /// - Returns: The current streak count. static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int { - var allCompletions = Set() - - // 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) - } - } - } - } - - if allCompletions.isEmpty { return 0 } - - // Count backwards from today + // Count backwards from today using perfect days (100% completion). 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) { + + // 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 - currentDayID = dayIdentifier(for: checkDate) } - - while allCompletions.contains(currentDayID) { + + while isPerfectDay(checkDate, rituals: rituals, calendar: calendar) { 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 } @@ -56,7 +41,7 @@ enum RitualAnalytics { let timeOfDay = TimeOfDay.current(for: date) return rituals.filter { ritual in - guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) != nil else { + guard ritual.activeArc(on: date) != nil else { return false } @@ -70,7 +55,7 @@ enum RitualAnalytics { var allTodayHabits: [ArcHabit] = [] for ritual in rituals { - if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) { + if let arc = ritual.arc(for: date) { allTodayHabits.append(contentsOf: arc.habits ?? []) } } @@ -81,27 +66,53 @@ enum RitualAnalytics { return Double(completedCount) / Double(allTodayHabits.count) } - /// Finds the next upcoming ritual (either later today or tomorrow). - static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? { + /// 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)? { 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 let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: currentDate) }) else { return false } + guard ritual.activeArc(on: currentDate) != nil else { return false } return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod } .sorted { $0.timeOfDay < $1.timeOfDay } .first + + if let laterToday { + return (laterToday, false) + } - if let laterToday { return laterToday } - - // 2. Try to find a ritual TOMORROW + // 2. Try to find a ritual TOMORROW. let tomorrowDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate) - return rituals.filter { ritual in - guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: tomorrowDate) }) else { return false } + let tomorrowRitual = rituals.filter { ritual in + guard ritual.activeArc(on: tomorrowDate) != nil 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) } } } diff --git a/AndromidaTests/AndromidaTests.swift b/AndromidaTests/AndromidaTests.swift index f7f301d..382e203 100644 --- a/AndromidaTests/AndromidaTests.swift +++ b/AndromidaTests/AndromidaTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import Andromida +@testable import Rituals struct AndromidaTests { diff --git a/AndromidaTests/RitualStoreTests.swift b/AndromidaTests/RitualStoreTests.swift index 601e019..cf1c48f 100644 --- a/AndromidaTests/RitualStoreTests.swift +++ b/AndromidaTests/RitualStoreTests.swift @@ -1,7 +1,7 @@ import Foundation import SwiftData import Testing -@testable import Andromida +@testable import Rituals struct RitualStoreTests { @MainActor @@ -53,7 +53,7 @@ struct RitualStoreTests { throw TestError.missingHabit } - #expect(ritual.arcs.count == 1) + #expect((ritual.arcs?.count ?? 0) == 1) #expect(ritual.currentArc?.arcNumber == 1) // End the current arc @@ -64,9 +64,127 @@ struct RitualStoreTests { // Renew the arc store.renewArc(for: ritual, durationDays: 30, copyHabits: true) - #expect(ritual.arcs.count == 2) + #expect((ritual.arcs?.count ?? 0) == 2) #expect(ritual.currentArc?.arcNumber == 2) - #expect(ritual.currentArc?.habits.count == 3) + #expect((ritual.currentArc?.habits?.count ?? 0) == 3) + } + + @MainActor + @Test func currentStreakCountsOnlyPerfectDays() throws { + let store = makeStore() + store.createQuickRitual() + + guard let ritual = store.activeRitual 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 + + // 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) } } @@ -92,4 +210,5 @@ private struct EmptySeedService: RitualSeedProviding { private enum TestError: Error { case missingHabit + case missingRitual } diff --git a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift index f794383..064dc67 100644 --- a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift +++ b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift @@ -107,7 +107,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { // Filter rituals for the target time of day let activeRituals = rituals.filter { ritual in - guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else { + guard ritual.activeArc(on: 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.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) { + if let arc = ritual.activeArc(on: today) { // Sort habits within each ritual by their sortIndex let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex } for habit in sortedHabits { @@ -147,21 +147,20 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider { // Next ritual info var nextRitualString: String? = nil - if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) { - let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate) - if isTomorrow { + if let nextContext = RitualAnalytics.nextUpcomingRitualContext(from: rituals, currentDate: targetDate) { + if nextContext.isTomorrow { let format = String(localized: "Next ritual: Tomorrow %@ (%@)") nextRitualString = String.localizedStringWithFormat( format, - nextRitual.timeOfDay.displayName, - nextRitual.timeOfDay.timeRange + nextContext.ritual.timeOfDay.displayName, + nextContext.ritual.timeOfDay.timeRange ) } else { let format = String(localized: "Next ritual: %@ (%@)") nextRitualString = String.localizedStringWithFormat( format, - nextRitual.timeOfDay.displayName, - nextRitual.timeOfDay.timeRange + nextContext.ritual.timeOfDay.displayName, + nextContext.ritual.timeOfDay.timeRange ) } } diff --git a/PRD.md b/PRD.md index 575852d..8ff932a 100644 --- a/PRD.md +++ b/PRD.md @@ -113,6 +113,7 @@ 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 @@ -122,7 +123,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 streak tracking | +| FR-INSIGHTS-03 | **Streak**: Current and longest perfect-day streak tracking (100% completion days) | | 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 | @@ -163,6 +164,7 @@ 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 @@ -360,7 +362,7 @@ Analytics display card. | Type | Description | |------|-------------| | Active | Active ritual count with breakdown | -| Streak | Current and longest streak | +| Streak | Current and longest perfect-day streak (100% completion days) | | HabitsToday | Today's completed habits | | Completion | Today's percentage with trend | | DaysActive | Total active days | diff --git a/README.md b/README.md index 5c9cc0b..0400c7c 100644 --- a/README.md +++ b/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/evening/anytime rituals) +- Time-of-day filtering (morning, midday, afternoon, evening, night, anytime) - Smart empty states (distinguishes "no rituals" from "no rituals for this time") - Fresh install starts clean (no pre-seeded rituals) @@ -40,13 +40,14 @@ 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, evening, anytime) +- Time-of-day scheduling (morning, midday, afternoon, evening, night, 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 @@ -57,7 +58,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 streak tracking +- **Streak**: Current and longest perfect-day streak tracking (100% completion days) - **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) @@ -73,6 +74,10 @@ 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