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