Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e0d7fa750f
commit
28c0282068
@ -629,7 +629,7 @@
|
|||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
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;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@ -653,7 +653,7 @@
|
|||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
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;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,6 +29,37 @@
|
|||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EAC04A972F26BAE8007F87EA"
|
||||||
|
BuildableName = "Rituals.app"
|
||||||
|
BlueprintName = "Andromida"
|
||||||
|
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EAC04AA42F26BAE9007F87EA"
|
||||||
|
BuildableName = "AndromidaTests.xctest"
|
||||||
|
BlueprintName = "AndromidaTests"
|
||||||
|
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "EAC04AAE2F26BAE9007F87EA"
|
||||||
|
BuildableName = "AndromidaUITests.xctest"
|
||||||
|
BlueprintName = "AndromidaUITests"
|
||||||
|
ReferencedContainer = "container:Andromida.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|||||||
@ -134,14 +134,16 @@ final class Ritual {
|
|||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
/// The currently active arc, if any.
|
/// The most recent arc flagged as active.
|
||||||
var currentArc: RitualArc? {
|
var currentArc: RitualArc? {
|
||||||
arcs?.first { $0.isActive }
|
(arcs ?? [])
|
||||||
|
.filter { $0.isActive }
|
||||||
|
.max { $0.startDate < $1.startDate }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this ritual has an active arc in progress.
|
/// Whether this ritual has an active arc in progress.
|
||||||
var hasActiveArc: Bool {
|
var hasActiveArc: Bool {
|
||||||
currentArc != nil
|
activeArc(on: Date()) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All arcs sorted by start date (newest first).
|
/// All arcs sorted by start date (newest first).
|
||||||
@ -156,12 +158,12 @@ final class Ritual {
|
|||||||
|
|
||||||
/// Total number of completed arcs.
|
/// Total number of completed arcs.
|
||||||
var completedArcCount: Int {
|
var completedArcCount: Int {
|
||||||
(arcs ?? []).filter { !$0.isActive }.count
|
completedArcs(asOf: Date()).count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end date of the most recently completed arc, if any.
|
/// The end date of the most recently completed arc, if any.
|
||||||
var lastCompletedDate: Date? {
|
var lastCompletedDate: Date? {
|
||||||
(arcs ?? []).filter { !$0.isActive }
|
completedArcs(asOf: Date())
|
||||||
.sorted { $0.endDate > $1.endDate }
|
.sorted { $0.endDate > $1.endDate }
|
||||||
.first?.endDate
|
.first?.endDate
|
||||||
}
|
}
|
||||||
@ -190,9 +192,23 @@ final class Ritual {
|
|||||||
|
|
||||||
// MARK: - Arc Queries
|
// 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.
|
/// Returns the arc that was active on a specific date, if any.
|
||||||
func arc(for date: Date) -> RitualArc? {
|
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.
|
/// Returns all arcs that overlap with a date range.
|
||||||
|
|||||||
@ -71,6 +71,22 @@ final class RitualArc {
|
|||||||
return checkDate >= start && checkDate <= end
|
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.
|
/// Returns the day index (1-based) for a given date within this arc.
|
||||||
func dayIndex(for date: Date) -> Int {
|
func dayIndex(for date: Date) -> Int {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
|
|||||||
@ -21,7 +21,7 @@ final class CategoryStore {
|
|||||||
|
|
||||||
/// Get a category by name
|
/// Get a category by name
|
||||||
func category(named name: String) -> Category? {
|
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
|
/// Get color for a category name, with fallback
|
||||||
@ -35,7 +35,7 @@ final class CategoryStore {
|
|||||||
guard !trimmedName.isEmpty else { return }
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
// Don't add if name already exists
|
// 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)
|
let newCategory = Category.create(name: trimmedName, colorName: colorName)
|
||||||
categories.append(newCategory)
|
categories.append(newCategory)
|
||||||
@ -58,7 +58,7 @@ final class CategoryStore {
|
|||||||
if let name {
|
if let name {
|
||||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// Ensure no duplicate names
|
// 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
|
categories[index].name = trimmedName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,11 +95,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var activeRitual: Ritual? {
|
var activeRitual: Ritual? {
|
||||||
// Return the first ritual with an active arc that covers today
|
currentRituals.first
|
||||||
currentRituals.first { ritual in
|
|
||||||
guard let arc = ritual.currentArc else { return false }
|
|
||||||
return arc.contains(date: Date())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var todayDisplayString: String {
|
var todayDisplayString: String {
|
||||||
@ -187,7 +183,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ritualDayIndex(for ritual: Ritual) -> Int {
|
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())
|
return arc.dayIndex(for: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,7 +217,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let timeOfDay = effectiveTimeOfDay()
|
let timeOfDay = effectiveTimeOfDay()
|
||||||
|
|
||||||
return currentRituals.filter { ritual in
|
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 false
|
||||||
}
|
}
|
||||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
||||||
@ -250,21 +246,23 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
arcsActive(on: date).flatMap { $0.habits ?? [] }
|
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).
|
/// Checks if a ritual's current arc has completed (past end date).
|
||||||
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
||||||
guard let arc = ritual.currentArc else { return false }
|
guard let arc = ritual.currentArc else { return false }
|
||||||
let today = calendar.startOfDay(for: Date())
|
return arc.isCompleted(asOf: Date(), calendar: calendar)
|
||||||
let endDate = calendar.startOfDay(for: arc.endDate)
|
|
||||||
return today > endDate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks for rituals that need renewal and triggers the prompt.
|
/// Checks for rituals that need renewal and triggers the prompt.
|
||||||
func checkForCompletedArcs() {
|
func checkForCompletedArcs() {
|
||||||
for ritual in currentRituals {
|
ritualNeedingRenewal = rituals.first { ritual in
|
||||||
if isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID) {
|
isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID)
|
||||||
ritualNeedingRenewal = ritual
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -478,8 +476,12 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func computeInsightCards() -> [InsightCard] {
|
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
|
// 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 totalHabits = activeHabitsToday.count
|
||||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||||
@ -488,10 +490,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
let daysActiveCount = datesWithActivity().count
|
let daysActiveCount = datesWithActivity().count
|
||||||
|
|
||||||
// Count rituals with active arcs
|
// Count rituals with active arcs
|
||||||
let activeRitualCount = currentRituals.count
|
let activeRitualCount = inProgressRituals.count
|
||||||
|
|
||||||
// Build per-ritual progress breakdown
|
// 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
|
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||||
return BreakdownItem(
|
return BreakdownItem(
|
||||||
label: ritual.title,
|
label: ritual.title,
|
||||||
@ -525,15 +527,15 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// Best ritual by completion rate
|
// Best ritual by completion rate
|
||||||
let bestRitualInfo: (title: String, rate: Int)? = {
|
let bestRitualInfo: (title: String, rate: Int)? = {
|
||||||
var best: (title: String, rate: Int)?
|
var best: (title: String, rate: Int)?
|
||||||
for ritual in currentRituals {
|
for ritual in inProgressRituals {
|
||||||
guard let arc = ritual.currentArc else { continue }
|
guard let arc = ritual.activeArc(on: Date()) else { continue }
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
guard !habits.isEmpty else { continue }
|
guard !habits.isEmpty else { continue }
|
||||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
let possibleCheckIns = habits.count * arc.dayIndex(for: Date())
|
let possibleCheckIns = habits.count * arc.dayIndex(for: Date())
|
||||||
guard possibleCheckIns > 0 else { continue }
|
guard possibleCheckIns > 0 else { continue }
|
||||||
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
||||||
if best == nil || rate > best!.rate {
|
if best.map({ rate > $0.rate }) ?? true {
|
||||||
best = (ritual.title, rate)
|
best = (ritual.title, rate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -549,7 +551,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
caption: String(localized: "In progress now"),
|
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."),
|
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",
|
symbolName: "sparkles",
|
||||||
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
breakdown: inProgressRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||||
),
|
),
|
||||||
.streak: InsightCard(
|
.streak: InsightCard(
|
||||||
type: .streak,
|
type: .streak,
|
||||||
@ -1103,11 +1105,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Returns nil if there's no previous arc to compare against.
|
/// Returns nil if there's no previous arc to compare against.
|
||||||
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
||||||
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
||||||
guard let currentArc = ritual.currentArc else { return nil }
|
guard let currentArc = ritual.activeArc(on: Date()) else { return nil }
|
||||||
|
|
||||||
// Find the previous arc (most recent completed arc)
|
// Find the previous arc (most recent completed arc)
|
||||||
let completedArcs = (ritual.arcs ?? [])
|
let completedArcs = ritual.completedArcs(asOf: Date())
|
||||||
.filter { !$0.isActive }
|
|
||||||
.sorted { $0.arcNumber > $1.arcNumber }
|
.sorted { $0.arcNumber > $1.arcNumber }
|
||||||
|
|
||||||
guard let previousArc = completedArcs.first else { return nil }
|
guard let previousArc = completedArcs.first else { return nil }
|
||||||
@ -1297,7 +1298,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Returns the insight context for tips generation.
|
/// Returns the insight context for tips generation.
|
||||||
func insightContext() -> InsightContext {
|
func insightContext() -> InsightContext {
|
||||||
let activeHabitsToday = habitsActive(on: Date())
|
let activeHabitsToday = habitsInProgress(on: Date())
|
||||||
let totalHabits = activeHabitsToday.count
|
let totalHabits = activeHabitsToday.count
|
||||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||||
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
||||||
|
|||||||
@ -27,11 +27,6 @@ struct HistoryView: View {
|
|||||||
private let baseMonthsToShow = 2
|
private let baseMonthsToShow = 2
|
||||||
private let monthChunkSize = 6
|
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
|
/// Grid columns for month cards - 2 columns on regular width, 1 on compact
|
||||||
private var monthColumns: [GridItem] {
|
private var monthColumns: [GridItem] {
|
||||||
AdaptiveColumns.columns(
|
AdaptiveColumns.columns(
|
||||||
|
|||||||
@ -26,11 +26,6 @@ struct ArcDetailView: View {
|
|||||||
|
|
||||||
private let calendar = Calendar.current
|
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
|
/// Grid columns for month calendars - 2 columns on regular width when multiple months
|
||||||
private var monthColumns: [GridItem] {
|
private var monthColumns: [GridItem] {
|
||||||
AdaptiveColumns.columns(
|
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
|
/// Returns completion rate for a date within this arc
|
||||||
private func arcCompletionRate(for date: Date) -> Double {
|
private func arcCompletionRate(for date: Date) -> Double {
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
@ -144,9 +123,7 @@ struct ArcDetailView: View {
|
|||||||
// Only return rate if date is within arc range
|
// Only return rate if date is within arc range
|
||||||
guard arc.contains(date: date) else { return 0 }
|
guard arc.contains(date: date) else { return 0 }
|
||||||
|
|
||||||
let dayFormatter = DateFormatter()
|
let dayID = RitualAnalytics.dayIdentifier(for: date)
|
||||||
dayFormatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
let dayID = dayFormatter.string(from: date)
|
|
||||||
|
|
||||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||||
return Double(completed) / Double(habits.count)
|
return Double(completed) / Double(habits.count)
|
||||||
|
|||||||
@ -20,11 +20,6 @@ struct RitualDetailView: View {
|
|||||||
self.ritual = ritual
|
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
|
/// Grid columns for habits - 2 columns on regular width when multiple habits
|
||||||
private var habitColumns: [GridItem] {
|
private var habitColumns: [GridItem] {
|
||||||
let habits = store.habits(for: ritual)
|
let habits = store.habits(for: ritual)
|
||||||
@ -67,7 +62,7 @@ struct RitualDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var completedArcs: [RitualArc] {
|
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)? {
|
private var arcComparisonInfo: (text: String, isAhead: Bool, isBehind: Bool)? {
|
||||||
@ -311,7 +306,7 @@ struct RitualDetailView: View {
|
|||||||
private var statusBadges: some View {
|
private var statusBadges: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
// Current arc indicator
|
// 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))
|
Text(String(localized: "Arc \(arc.arcNumber)")).styled(.captionEmphasis, emphasis: .custom(AppAccent.primary))
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
|||||||
@ -6,19 +6,19 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
|
|
||||||
private var currentTimePeriod: TimeOfDay {
|
private var currentTimePeriod: TimeOfDay {
|
||||||
TimeOfDay.current()
|
store.effectiveTimeOfDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var nextRituals: [Ritual] {
|
private var nextRituals: [Ritual] {
|
||||||
// Find rituals scheduled for later time periods TODAY
|
// Find rituals scheduled for later time periods TODAY
|
||||||
store.currentRituals.filter { ritual in
|
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
|
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var nextRitualTomorrow: Ritual? {
|
private var nextRitualContext: (ritual: Ritual, isTomorrow: Bool)? {
|
||||||
RitualAnalytics.nextUpcomingRitual(from: store.currentRituals)
|
RitualAnalytics.nextUpcomingRitualContext(from: store.currentRituals)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -71,17 +71,30 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
} else if let tomorrowRitual = nextRitualTomorrow {
|
} else if let nextContext = nextRitualContext {
|
||||||
|
if nextContext.isTomorrow {
|
||||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||||
Text(String.localizedStringWithFormat(
|
Text(String.localizedStringWithFormat(
|
||||||
format,
|
format,
|
||||||
tomorrowRitual.timeOfDay.displayName,
|
nextContext.ritual.timeOfDay.displayName,
|
||||||
tomorrowRitual.timeOfDay.timeRange
|
nextContext.ritual.timeOfDay.timeRange
|
||||||
))
|
))
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, Design.Spacing.small)
|
.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
|
// Motivational message
|
||||||
|
|||||||
@ -3,48 +3,33 @@ import Foundation
|
|||||||
/// Shared logic for ritual analytics and data processing used by both the app and widgets.
|
/// Shared logic for ritual analytics and data processing used by both the app and widgets.
|
||||||
enum RitualAnalytics {
|
enum RitualAnalytics {
|
||||||
/// Returns a unique string identifier for a given date (YYYY-MM-DD).
|
/// Returns a unique string identifier for a given date (YYYY-MM-DD).
|
||||||
static func dayIdentifier(for date: Date) -> String {
|
static func dayIdentifier(for date: Date, calendar: Calendar = .current) -> String {
|
||||||
let formatter = DateFormatter()
|
let localDate = calendar.startOfDay(for: date)
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
let components = calendar.dateComponents([.year, .month, .day], from: localDate)
|
||||||
return formatter.string(from: date)
|
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:
|
/// - Parameters:
|
||||||
/// - rituals: The list of rituals to analyze.
|
/// - rituals: The list of rituals to analyze.
|
||||||
/// - calendar: The calendar to use for date calculations.
|
/// - calendar: The calendar to use for date calculations.
|
||||||
/// - Returns: The current streak count.
|
/// - Returns: The current streak count.
|
||||||
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int {
|
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int {
|
||||||
var allCompletions = Set<String>()
|
// Count backwards from today using perfect days (100% completion).
|
||||||
|
|
||||||
// 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
|
|
||||||
var streak = 0
|
var streak = 0
|
||||||
var checkDate = calendar.startOfDay(for: Date())
|
var checkDate = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
// If today isn't perfect, check if yesterday was to maintain streak
|
// If today isn't perfect, start from yesterday.
|
||||||
var currentDayID = dayIdentifier(for: checkDate)
|
if !isPerfectDay(checkDate, rituals: rituals, calendar: calendar) {
|
||||||
if !allCompletions.contains(currentDayID) {
|
|
||||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
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
|
streak += 1
|
||||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||||
currentDayID = dayIdentifier(for: checkDate)
|
|
||||||
if streak > 3650 { break } // Safety cap (10 years)
|
if streak > 3650 { break } // Safety cap (10 years)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +41,7 @@ enum RitualAnalytics {
|
|||||||
let timeOfDay = TimeOfDay.current(for: date)
|
let timeOfDay = TimeOfDay.current(for: date)
|
||||||
|
|
||||||
return rituals.filter { ritual in
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +55,7 @@ enum RitualAnalytics {
|
|||||||
var allTodayHabits: [ArcHabit] = []
|
var allTodayHabits: [ArcHabit] = []
|
||||||
|
|
||||||
for ritual in rituals {
|
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 ?? [])
|
allTodayHabits.append(contentsOf: arc.habits ?? [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,27 +66,53 @@ enum RitualAnalytics {
|
|||||||
return Double(completedCount) / Double(allTodayHabits.count)
|
return Double(completedCount) / Double(allTodayHabits.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds the next upcoming ritual (either later today or tomorrow).
|
/// Finds the next upcoming ritual and whether it occurs tomorrow.
|
||||||
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
|
static func nextUpcomingRitualContext(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> (ritual: Ritual, isTomorrow: Bool)? {
|
||||||
let currentTimePeriod = TimeOfDay.current(for: currentDate)
|
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
|
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
|
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||||
}
|
}
|
||||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
.first
|
.first
|
||||||
|
|
||||||
if let laterToday { return laterToday }
|
if let laterToday {
|
||||||
|
return (laterToday, false)
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
let tomorrowDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate)
|
||||||
return rituals.filter { ritual in
|
let tomorrowRitual = rituals.filter { ritual in
|
||||||
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: tomorrowDate) }) else { return false }
|
guard ritual.activeArc(on: tomorrowDate) != nil else { return false }
|
||||||
return ritual.timeOfDay != .anytime
|
return ritual.timeOfDay != .anytime
|
||||||
}
|
}
|
||||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
.first
|
.first
|
||||||
|
|
||||||
|
if let tomorrowRitual {
|
||||||
|
return (tomorrowRitual, true)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the next upcoming ritual (either later today or tomorrow).
|
||||||
|
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
|
||||||
|
nextUpcomingRitualContext(from: rituals, currentDate: currentDate, calendar: calendar)?.ritual
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isPerfectDay(_ date: Date, rituals: [Ritual], calendar: Calendar) -> Bool {
|
||||||
|
let dayID = dayIdentifier(for: date, calendar: calendar)
|
||||||
|
var activeHabits: [ArcHabit] = []
|
||||||
|
|
||||||
|
for ritual in rituals {
|
||||||
|
if let arc = ritual.arc(for: date) {
|
||||||
|
activeHabits.append(contentsOf: arc.habits ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !activeHabits.isEmpty else { return false }
|
||||||
|
return activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Andromida
|
@testable import Rituals
|
||||||
|
|
||||||
struct AndromidaTests {
|
struct AndromidaTests {
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Andromida
|
@testable import Rituals
|
||||||
|
|
||||||
struct RitualStoreTests {
|
struct RitualStoreTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -53,7 +53,7 @@ struct RitualStoreTests {
|
|||||||
throw TestError.missingHabit
|
throw TestError.missingHabit
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(ritual.arcs.count == 1)
|
#expect((ritual.arcs?.count ?? 0) == 1)
|
||||||
#expect(ritual.currentArc?.arcNumber == 1)
|
#expect(ritual.currentArc?.arcNumber == 1)
|
||||||
|
|
||||||
// End the current arc
|
// End the current arc
|
||||||
@ -64,9 +64,127 @@ struct RitualStoreTests {
|
|||||||
// Renew the arc
|
// Renew the arc
|
||||||
store.renewArc(for: ritual, durationDays: 30, copyHabits: true)
|
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?.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 {
|
private enum TestError: Error {
|
||||||
case missingHabit
|
case missingHabit
|
||||||
|
case missingRitual
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
// Filter rituals for the target time of day
|
// Filter rituals for the target time of day
|
||||||
let activeRituals = rituals.filter { ritual in
|
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 false
|
||||||
}
|
}
|
||||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
||||||
@ -121,7 +121,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
var visibleHabits: [HabitEntry] = []
|
var visibleHabits: [HabitEntry] = []
|
||||||
for ritual in activeRituals {
|
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
|
// Sort habits within each ritual by their sortIndex
|
||||||
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||||
for habit in sortedHabits {
|
for habit in sortedHabits {
|
||||||
@ -147,21 +147,20 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
// Next ritual info
|
// Next ritual info
|
||||||
var nextRitualString: String? = nil
|
var nextRitualString: String? = nil
|
||||||
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
|
if let nextContext = RitualAnalytics.nextUpcomingRitualContext(from: rituals, currentDate: targetDate) {
|
||||||
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
|
if nextContext.isTomorrow {
|
||||||
if isTomorrow {
|
|
||||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||||
nextRitualString = String.localizedStringWithFormat(
|
nextRitualString = String.localizedStringWithFormat(
|
||||||
format,
|
format,
|
||||||
nextRitual.timeOfDay.displayName,
|
nextContext.ritual.timeOfDay.displayName,
|
||||||
nextRitual.timeOfDay.timeRange
|
nextContext.ritual.timeOfDay.timeRange
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let format = String(localized: "Next ritual: %@ (%@)")
|
let format = String(localized: "Next ritual: %@ (%@)")
|
||||||
nextRitualString = String.localizedStringWithFormat(
|
nextRitualString = String.localizedStringWithFormat(
|
||||||
format,
|
format,
|
||||||
nextRitual.timeOfDay.displayName,
|
nextContext.ritual.timeOfDay.displayName,
|
||||||
nextRitual.timeOfDay.timeRange
|
nextContext.ritual.timeOfDay.timeRange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
PRD.md
6
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-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-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-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
|
### 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-01 | Display 8 tappable insight cards with full-screen detail sheets |
|
||||||
| FR-INSIGHTS-02 | **Active Rituals**: Count with per-ritual breakdown |
|
| 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-04 | **Habits Today**: Completed count with per-ritual breakdown |
|
||||||
| FR-INSIGHTS-05 | **Completion**: Today's percentage with 7-day trend chart |
|
| FR-INSIGHTS-05 | **Completion**: Today's percentage with 7-day trend chart |
|
||||||
| FR-INSIGHTS-06 | **Days Active**: Total active days with detailed breakdown |
|
| 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-07 | Support App Intents for widget configuration |
|
||||||
| FR-WIDGET-08 | Update widget content every 15 minutes |
|
| FR-WIDGET-08 | Update widget content every 15 minutes |
|
||||||
| FR-WIDGET-09 | Use App Group shared container for SwiftData access |
|
| 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
|
### 3.7 Onboarding
|
||||||
|
|
||||||
@ -360,7 +362,7 @@ Analytics display card.
|
|||||||
| Type | Description |
|
| Type | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| Active | Active ritual count with breakdown |
|
| 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 |
|
| HabitsToday | Today's completed habits |
|
||||||
| Completion | Today's percentage with trend |
|
| Completion | Today's percentage with trend |
|
||||||
| DaysActive | Total active days |
|
| DaysActive | Total active days |
|
||||||
|
|||||||
11
README.md
11
README.md
@ -14,7 +14,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
|||||||
### Today Tab
|
### Today Tab
|
||||||
- Focus ritual cards with progress rings
|
- Focus ritual cards with progress rings
|
||||||
- Tap-to-complete habit check-ins with haptic/sound feedback
|
- 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")
|
- Smart empty states (distinguishes "no rituals" from "no rituals for this time")
|
||||||
- Fresh install starts clean (no pre-seeded rituals)
|
- 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
|
- Habit icon picker with 100+ icons organized by category
|
||||||
- Custom category input (beyond preset categories)
|
- Custom category input (beyond preset categories)
|
||||||
- Flexible duration: slider (7-365 days) + quick presets + custom input
|
- 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
|
- Drag-to-reorder habits
|
||||||
|
|
||||||
### History Tab
|
### History Tab
|
||||||
- Scrollable month calendar grid
|
- Scrollable month calendar grid
|
||||||
- Daily progress rings with color coding (green=100%, accent=50%+, gray=<50%)
|
- Daily progress rings with color coding (green=100%, accent=50%+, gray=<50%)
|
||||||
- Filter by ritual using horizontal pill picker
|
- Filter by ritual using horizontal pill picker
|
||||||
|
- "All" mode includes historical data from completed and archived arcs
|
||||||
- Tap any day for detail sheet showing:
|
- Tap any day for detail sheet showing:
|
||||||
- Progress ring with percentage
|
- Progress ring with percentage
|
||||||
- Comparison to weekly average
|
- Comparison to weekly average
|
||||||
@ -57,7 +58,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
|||||||
### Insights Tab
|
### Insights Tab
|
||||||
- Tappable insight cards with full-screen detail sheets
|
- Tappable insight cards with full-screen detail sheets
|
||||||
- **Active Rituals**: Count with per-ritual breakdown
|
- **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
|
- **Habits Today**: Completed count with per-ritual breakdown
|
||||||
- **Completion**: Today's percentage with 7-day trend chart
|
- **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)
|
- **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
|
- iCloud settings sync
|
||||||
- Debug tools: reset onboarding, app icon generation, branding preview
|
- 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
|
### Onboarding
|
||||||
- Setup wizard on first launch (goal, time, ritual preview, first check-in)
|
- Setup wizard on first launch (goal, time, ritual preview, first check-in)
|
||||||
- Ends with a quick orientation to Today, Rituals, and Insights
|
- Ends with a quick orientation to Today, Rituals, and Insights
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user