Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b67de2454a
commit
d950bdcbbf
444
Andromida/App/State/RitualStore+Analytics.swift
Normal file
444
Andromida/App/State/RitualStore+Analytics.swift
Normal file
@ -0,0 +1,444 @@
|
||||
import Foundation
|
||||
import Bedrock
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
func refreshAnalyticsCacheIfNeeded() {
|
||||
guard analyticsNeedsRefresh else { return }
|
||||
cachedDatesWithActivity = computeDatesWithActivity()
|
||||
cachedPerfectDayIDs = computePerfectDays(from: cachedDatesWithActivity)
|
||||
analyticsNeedsRefresh = false
|
||||
}
|
||||
|
||||
func invalidateAnalyticsCache() {
|
||||
analyticsNeedsRefresh = true
|
||||
insightCardsNeedRefresh = true
|
||||
}
|
||||
|
||||
private func computeDatesWithActivity() -> Set<Date> {
|
||||
var dates: Set<Date> = []
|
||||
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs ?? [] {
|
||||
for habit in arc.habits ?? [] {
|
||||
for dayID in habit.completedDayIDs {
|
||||
if let date = dayFormatter.date(from: dayID) {
|
||||
dates.insert(calendar.startOfDay(for: date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
|
||||
guard !activeDates.isEmpty else { return [] }
|
||||
|
||||
var perfectDayIDs: Set<String> = []
|
||||
|
||||
for date in activeDates {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let activeHabits = habitsActive(on: date)
|
||||
|
||||
guard !activeHabits.isEmpty else { continue }
|
||||
|
||||
let allCompleted = activeHabits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
if allCompleted {
|
||||
perfectDayIDs.insert(dayID)
|
||||
}
|
||||
}
|
||||
|
||||
return perfectDayIDs
|
||||
}
|
||||
|
||||
func scheduleReminderUpdate() {
|
||||
// Avoid touching live SwiftData model graphs from async reminder tasks during XCTest runs.
|
||||
guard !isRunningTests else { return }
|
||||
pendingReminderTask?.cancel()
|
||||
let ritualsSnapshot = rituals
|
||||
pendingReminderTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
await reminderScheduler.updateReminders(for: ritualsSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func dayIdentifier(for date: Date) -> String {
|
||||
RitualAnalytics.dayIdentifier(for: date)
|
||||
}
|
||||
|
||||
// MARK: - History / Calendar Support
|
||||
|
||||
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
||||
/// This correctly queries habits from arcs that were active on that date.
|
||||
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
||||
if let ritual = ritual {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
guard let arc = ritual.arc(for: date) else { return 0 }
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||
return Double(completed) / Double(habits.count)
|
||||
} else {
|
||||
return RitualAnalytics.overallCompletionRate(on: date, from: rituals)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all dates that have any habit activity.
|
||||
func datesWithActivity() -> Set<Date> {
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
return cachedDatesWithActivity
|
||||
}
|
||||
|
||||
/// Returns the earliest date with any ritual activity.
|
||||
func earliestActivityDate() -> Date? {
|
||||
datesWithActivity().min()
|
||||
}
|
||||
|
||||
/// Returns habit completion details for a specific date.
|
||||
/// This correctly queries habits from arcs that were active on that date.
|
||||
func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
|
||||
var completions: [HabitCompletion] = []
|
||||
|
||||
if let ritual = ritual {
|
||||
// Get habits from the arc that was active on this date
|
||||
if let arc = ritual.arc(for: date) {
|
||||
for habit in arc.habits ?? [] {
|
||||
completions.append(HabitCompletion(
|
||||
habit: habit,
|
||||
ritualTitle: ritual.title,
|
||||
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all habits from all arcs that were active on this date
|
||||
for r in rituals {
|
||||
if let arc = r.arc(for: date) {
|
||||
for habit in arc.habits ?? [] {
|
||||
completions.append(HabitCompletion(
|
||||
habit: habit,
|
||||
ritualTitle: r.title,
|
||||
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
/// Checks if a habit was completed on a specific date.
|
||||
func isHabitCompleted(_ habit: ArcHabit, on date: Date) -> Bool {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
return habit.completedDayIDs.contains(dayID)
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Analytics
|
||||
|
||||
/// Returns the weekly average completion rate for the week containing the given date.
|
||||
func weeklyAverageForDate(_ date: Date) -> Double {
|
||||
guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalRate = 0.0
|
||||
var daysWithData = 0
|
||||
|
||||
for dayOffset in 0..<7 {
|
||||
guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue }
|
||||
let rate = completionRate(for: day)
|
||||
if rate > 0 || !habitsActive(on: day).isEmpty {
|
||||
totalRate += rate
|
||||
daysWithData += 1
|
||||
}
|
||||
}
|
||||
|
||||
return daysWithData > 0 ? totalRate / Double(daysWithData) : 0
|
||||
}
|
||||
|
||||
/// Returns the streak length that includes the given date, or nil if the date wasn't a perfect day.
|
||||
func streakIncluding(_ date: Date) -> Int? {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let perfect = perfectDays()
|
||||
|
||||
guard perfect.contains(dayID) else { return nil }
|
||||
|
||||
// Count backwards from the date
|
||||
var streakBefore = 0
|
||||
var checkDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: date)) ?? date
|
||||
while perfect.contains(dayIdentifier(for: checkDate)) {
|
||||
streakBefore += 1
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
}
|
||||
|
||||
// Count forwards from the date
|
||||
var streakAfter = 0
|
||||
checkDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) ?? date
|
||||
while perfect.contains(dayIdentifier(for: checkDate)) {
|
||||
streakAfter += 1
|
||||
checkDate = calendar.date(byAdding: .day, value: 1, to: checkDate) ?? checkDate
|
||||
}
|
||||
|
||||
return streakBefore + 1 + streakAfter
|
||||
}
|
||||
|
||||
/// Returns the number of days remaining in the ritual's current arc.
|
||||
func daysRemaining(for ritual: Ritual) -> Int {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||
let today = calendar.startOfDay(for: now())
|
||||
let endDate = calendar.startOfDay(for: arc.endDate)
|
||||
|
||||
let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0
|
||||
return max(0, days)
|
||||
}
|
||||
|
||||
/// Returns the current streak for a specific ritual's current arc.
|
||||
func streakForRitual(_ ritual: Ritual) -> Int {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return 0 }
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
|
||||
var streak = 0
|
||||
var checkDate = calendar.startOfDay(for: now())
|
||||
|
||||
while arc.contains(date: checkDate) {
|
||||
let dayID = dayIdentifier(for: checkDate)
|
||||
let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
|
||||
if allCompleted {
|
||||
streak += 1
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
/// Compares current arc completion rate to the previous arc at the same day index.
|
||||
/// Returns nil if there's no previous arc to compare against.
|
||||
/// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex)
|
||||
func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? {
|
||||
let currentDate = now()
|
||||
guard let currentArc = ritual.activeArc(on: currentDate) else { return nil }
|
||||
|
||||
// Find the previous arc (most recent completed arc)
|
||||
let completedArcs = ritual.completedArcs(asOf: currentDate)
|
||||
.sorted { $0.arcNumber > $1.arcNumber }
|
||||
|
||||
guard let previousArc = completedArcs.first else { return nil }
|
||||
|
||||
let currentDayIndex = ritualDayIndex(for: ritual)
|
||||
guard currentDayIndex > 0 else { return nil }
|
||||
|
||||
// Calculate current arc's completion rate up to today
|
||||
let currentHabits = currentArc.habits ?? []
|
||||
guard !currentHabits.isEmpty else { return nil }
|
||||
|
||||
let currentCheckIns = currentHabits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
let currentPossible = currentHabits.count * currentDayIndex
|
||||
let currentRate = currentPossible > 0 ? Double(currentCheckIns) / Double(currentPossible) : 0
|
||||
|
||||
// Calculate previous arc's completion rate at the same day index
|
||||
let previousHabits = previousArc.habits ?? []
|
||||
guard !previousHabits.isEmpty else { return nil }
|
||||
|
||||
// Count check-ins from previous arc up to the same day index
|
||||
let previousDayIDs = generateDayIDs(from: previousArc.startDate, days: currentDayIndex)
|
||||
let previousCheckIns = previousHabits.reduce(0) { total, habit in
|
||||
total + habit.completedDayIDs.filter { previousDayIDs.contains($0) }.count
|
||||
}
|
||||
let previousPossible = previousHabits.count * currentDayIndex
|
||||
let previousRate = previousPossible > 0 ? Double(previousCheckIns) / Double(previousPossible) : 0
|
||||
|
||||
let delta = Int((currentRate - previousRate) * 100)
|
||||
return (delta, previousArc.arcNumber, currentDayIndex)
|
||||
}
|
||||
|
||||
/// Generates day ID strings for a range of days starting from a date.
|
||||
private func generateDayIDs(from startDate: Date, days: Int) -> Set<String> {
|
||||
var ids: Set<String> = []
|
||||
let start = calendar.startOfDay(for: startDate)
|
||||
for i in 0..<days {
|
||||
if let date = calendar.date(byAdding: .day, value: i, to: start) {
|
||||
ids.insert(dayIdentifier(for: date))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// Returns completion rates for each habit in a ritual's current arc.
|
||||
func habitCompletionRates(for ritual: Ritual) -> [(habit: ArcHabit, rate: Double)] {
|
||||
let currentDay = ritualDayIndex(for: ritual)
|
||||
guard currentDay > 0 else { return ritual.habits.map { ($0, 0.0) } }
|
||||
|
||||
return ritual.habits.map { habit in
|
||||
let completedDays = habit.completedDayIDs.count
|
||||
let rate = Double(completedDays) / Double(currentDay)
|
||||
return (habit, min(rate, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Arc-Specific Analytics
|
||||
|
||||
/// Returns completion rates for each habit in a specific arc.
|
||||
func habitCompletionRates(for arc: RitualArc) -> [(habit: ArcHabit, rate: Double)] {
|
||||
let habits = arc.habits ?? []
|
||||
let totalDays = arc.durationDays
|
||||
guard totalDays > 0 else { return habits.map { ($0, 0.0) } }
|
||||
|
||||
return habits.map { habit in
|
||||
let completedDays = habit.completedDayIDs.count
|
||||
let rate = Double(completedDays) / Double(totalDays)
|
||||
return (habit, min(rate, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the longest streak of consecutive perfect days within a specific arc.
|
||||
func longestStreak(for arc: RitualArc) -> Int {
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
|
||||
let arcDayIDs = generateDayIDs(from: arc.startDate, days: arc.durationDays)
|
||||
|
||||
// Find which days had all habits completed
|
||||
var perfectDayIDs: Set<String> = []
|
||||
for dayID in arcDayIDs {
|
||||
let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
if allCompleted {
|
||||
perfectDayIDs.insert(dayID)
|
||||
}
|
||||
}
|
||||
|
||||
guard !perfectDayIDs.isEmpty else { return 0 }
|
||||
|
||||
// Convert to dates and sort
|
||||
let sortedDates = perfectDayIDs.compactMap { dayFormatter.date(from: $0) }.sorted()
|
||||
guard !sortedDates.isEmpty else { return 0 }
|
||||
|
||||
var longest = 1
|
||||
var current = 1
|
||||
|
||||
for i in 1..<sortedDates.count {
|
||||
let prev = sortedDates[i - 1]
|
||||
let curr = sortedDates[i]
|
||||
|
||||
if let nextDay = calendar.date(byAdding: .day, value: 1, to: prev),
|
||||
calendar.isDate(nextDay, inSameDayAs: curr) {
|
||||
current += 1
|
||||
longest = max(longest, current)
|
||||
} else {
|
||||
current = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
/// Returns the count of perfect days (100% completion) within a specific arc.
|
||||
func perfectDaysCount(for arc: RitualArc) -> Int {
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
|
||||
let arcDayIDs = generateDayIDs(from: arc.startDate, days: arc.durationDays)
|
||||
|
||||
var count = 0
|
||||
for dayID in arcDayIDs {
|
||||
let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
|
||||
if allCompleted {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/// Returns completion rate for each day in a specific arc (for heatmap display).
|
||||
func dailyCompletionRates(for arc: RitualArc) -> [(date: Date, rate: Double)] {
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { return [] }
|
||||
|
||||
var results: [(date: Date, rate: Double)] = []
|
||||
let start = calendar.startOfDay(for: arc.startDate)
|
||||
|
||||
for dayOffset in 0..<arc.durationDays {
|
||||
guard let date = calendar.date(byAdding: .day, value: dayOffset, to: start) else { continue }
|
||||
let dayID = dayIdentifier(for: date)
|
||||
|
||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||
let rate = Double(completed) / Double(habits.count)
|
||||
results.append((date, rate))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/// Returns milestones for a ritual with achievement status.
|
||||
func milestonesAchieved(for ritual: Ritual) -> [Milestone] {
|
||||
let currentDay = ritualDayIndex(for: ritual)
|
||||
return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays)
|
||||
}
|
||||
|
||||
/// Returns the week-over-week change in completion rate.
|
||||
func weekOverWeekChange() -> Double {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let thisWeekAvg = weeklyAverageForDate(today)
|
||||
let lastWeekAvg = weeklyAverageForDate(lastWeekStart)
|
||||
|
||||
guard lastWeekAvg > 0 else { return thisWeekAvg > 0 ? 1.0 : 0 }
|
||||
return (thisWeekAvg - lastWeekAvg) / lastWeekAvg
|
||||
}
|
||||
|
||||
/// Returns a motivational message based on the completion rate.
|
||||
func motivationalMessage(for rate: Double) -> String {
|
||||
switch rate {
|
||||
case 1.0:
|
||||
return String(localized: "Perfect day! You crushed it.")
|
||||
case 0.75..<1.0:
|
||||
return String(localized: "Great progress! Almost there.")
|
||||
case 0.5..<0.75:
|
||||
return String(localized: "Solid effort. Keep building momentum.")
|
||||
case 0.25..<0.5:
|
||||
return String(localized: "Every habit counts. You've got this.")
|
||||
case 0.01..<0.25:
|
||||
return String(localized: "Small steps lead to big changes.")
|
||||
default:
|
||||
return String(localized: "Tomorrow is a fresh start.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the insight context for tips generation.
|
||||
func insightContext() -> InsightContext {
|
||||
let activeHabitsToday = habitsInProgress(on: now())
|
||||
let totalHabits = activeHabitsToday.count
|
||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||
let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0
|
||||
|
||||
return InsightContext(
|
||||
completionRate: rate,
|
||||
currentStreak: currentStreak(),
|
||||
longestStreak: longestStreak(),
|
||||
daysActive: datesWithActivity().count,
|
||||
totalHabits: totalHabits,
|
||||
completedToday: completedToday
|
||||
)
|
||||
}
|
||||
|
||||
/// Precomputes analytics data if it is invalidated.
|
||||
func refreshAnalyticsIfNeeded() {
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
105
Andromida/App/State/RitualStore+Arcs.swift
Normal file
105
Andromida/App/State/RitualStore+Arcs.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Bedrock
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Arc Management
|
||||
|
||||
/// Returns all arcs that were active on a specific date.
|
||||
func arcsActive(on date: Date) -> [RitualArc] {
|
||||
rituals.flatMap { $0.arcs ?? [] }.filter { $0.contains(date: date) }
|
||||
}
|
||||
|
||||
/// Returns habits from all arcs that were active on a specific date.
|
||||
func habitsActive(on date: Date) -> [ArcHabit] {
|
||||
arcsActive(on: date).flatMap { $0.habits ?? [] }
|
||||
}
|
||||
|
||||
/// Returns habits from arcs currently in progress on the provided date.
|
||||
func habitsInProgress(on date: Date) -> [ArcHabit] {
|
||||
currentRituals
|
||||
.compactMap { $0.activeArc(on: date) }
|
||||
.flatMap { $0.habits ?? [] }
|
||||
}
|
||||
|
||||
/// Checks if a ritual's current arc has completed (past end date).
|
||||
func isArcCompleted(_ ritual: Ritual) -> Bool {
|
||||
guard let arc = ritual.currentArc else { return false }
|
||||
return arc.isCompleted(asOf: now(), calendar: calendar)
|
||||
}
|
||||
|
||||
/// Checks for rituals that need renewal and triggers the prompt.
|
||||
func checkForCompletedArcs() {
|
||||
ritualNeedingRenewal = rituals.first { ritual in
|
||||
isArcCompleted(ritual) && !dismissedRenewalRituals.contains(ritual.persistentModelID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renews a ritual by creating a new arc, optionally copying habits from the previous arc.
|
||||
/// - Parameters:
|
||||
/// - ritual: The ritual to renew
|
||||
/// - durationDays: Duration for the new arc (defaults to ritual's default)
|
||||
/// - copyHabits: Whether to copy habits from the previous arc
|
||||
func renewArc(for ritual: Ritual, durationDays: Int? = nil, copyHabits: Bool = true) {
|
||||
// Clear dismissed status since user is taking action
|
||||
dismissedRenewalRituals.remove(ritual.persistentModelID)
|
||||
|
||||
// Mark current arc as inactive
|
||||
if let currentArc = ritual.currentArc {
|
||||
currentArc.isActive = false
|
||||
}
|
||||
|
||||
// Create new arc
|
||||
let duration = durationDays ?? ritual.defaultDurationDays
|
||||
let newArcNumber = (ritual.latestArc?.arcNumber ?? 0) + 1
|
||||
|
||||
let newHabits: [ArcHabit]
|
||||
if copyHabits, let previousArc = ritual.latestArc {
|
||||
newHabits = (previousArc.habits ?? []).map { $0.copyForNewArc() }
|
||||
} else {
|
||||
newHabits = []
|
||||
}
|
||||
|
||||
let newArc = RitualArc(
|
||||
startDate: now(),
|
||||
durationDays: duration,
|
||||
arcNumber: newArcNumber,
|
||||
isActive: true,
|
||||
habits: newHabits
|
||||
)
|
||||
|
||||
var arcs = ritual.arcs ?? []
|
||||
arcs.append(newArc)
|
||||
ritual.arcs = arcs
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Starts a new arc for a past ritual (one without an active arc).
|
||||
/// - Parameters:
|
||||
/// - ritual: The ritual to start
|
||||
/// - durationDays: Duration for the new arc (defaults to ritual's default)
|
||||
func startNewArc(for ritual: Ritual, durationDays: Int? = nil) {
|
||||
renewArc(for: ritual, durationDays: durationDays, copyHabits: true)
|
||||
}
|
||||
|
||||
/// Ends a ritual without renewal (marks it as having no active arc).
|
||||
func endArc(for ritual: Ritual) {
|
||||
// Clear dismissed status since user is taking action
|
||||
dismissedRenewalRituals.remove(ritual.persistentModelID)
|
||||
|
||||
if let currentArc = ritual.currentArc {
|
||||
currentArc.isActive = false
|
||||
saveAndReload()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses the renewal prompt without taking action.
|
||||
func dismissRenewalPrompt() {
|
||||
if let ritual = ritualNeedingRenewal {
|
||||
dismissedRenewalRituals.insert(ritual.persistentModelID)
|
||||
}
|
||||
ritualNeedingRenewal = nil
|
||||
}
|
||||
|
||||
}
|
||||
157
Andromida/App/State/RitualStore+Data.swift
Normal file
157
Andromida/App/State/RitualStore+Data.swift
Normal file
@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Data Lifecycle
|
||||
|
||||
func loadRitualsIfNeeded() {
|
||||
reloadData()
|
||||
// No longer auto-seed rituals on fresh install
|
||||
// Users start with empty state and create their own rituals
|
||||
}
|
||||
|
||||
/// Performs a one-time migration to repair arc integrity and normalize sort indexes.
|
||||
/// - Parameter force: Set true to run regardless of stored migration version (used in tests).
|
||||
func runDataIntegrityMigrationIfNeeded(force: Bool = false) {
|
||||
let previousVersion = UserDefaults.standard.integer(forKey: Self.dataIntegrityMigrationVersionKey)
|
||||
guard force || previousVersion < Self.dataIntegrityMigrationVersion else { return }
|
||||
|
||||
do {
|
||||
let fetchedRituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||
let didChange = applyDataIntegrityMigration(to: fetchedRituals)
|
||||
|
||||
if didChange {
|
||||
try modelContext.save()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(Self.dataIntegrityMigrationVersion, forKey: Self.dataIntegrityMigrationVersionKey)
|
||||
reloadData()
|
||||
} catch {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDataIntegrityMigration(to rituals: [Ritual]) -> Bool {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
var didChange = false
|
||||
|
||||
// Normalize ritual ordering indexes in the same order the app presents current rituals.
|
||||
let sortedRituals = rituals.sorted { lhs, rhs in
|
||||
if lhs.timeOfDay != rhs.timeOfDay {
|
||||
return lhs.timeOfDay < rhs.timeOfDay
|
||||
}
|
||||
if lhs.sortIndex != rhs.sortIndex {
|
||||
return lhs.sortIndex < rhs.sortIndex
|
||||
}
|
||||
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
||||
}
|
||||
|
||||
for (ritualIndex, ritual) in sortedRituals.enumerated() {
|
||||
if ritual.sortIndex != ritualIndex {
|
||||
ritual.sortIndex = ritualIndex
|
||||
didChange = true
|
||||
}
|
||||
|
||||
let arcs = ritual.arcs ?? []
|
||||
var inProgressActiveArcs: [RitualArc] = []
|
||||
|
||||
for arc in arcs {
|
||||
let normalizedStart = calendar.startOfDay(for: arc.startDate)
|
||||
if arc.startDate != normalizedStart {
|
||||
arc.startDate = normalizedStart
|
||||
didChange = true
|
||||
}
|
||||
|
||||
let normalizedEnd = calendar.startOfDay(for: arc.endDate)
|
||||
if arc.endDate != normalizedEnd {
|
||||
arc.endDate = normalizedEnd
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if arc.endDate < arc.startDate {
|
||||
arc.endDate = arc.startDate
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if arc.isInProgress(on: today, calendar: calendar) {
|
||||
inProgressActiveArcs.append(arc)
|
||||
}
|
||||
|
||||
let sortedHabits = (arc.habits ?? []).sorted { lhs, rhs in
|
||||
if lhs.sortIndex != rhs.sortIndex {
|
||||
return lhs.sortIndex < rhs.sortIndex
|
||||
}
|
||||
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
|
||||
}
|
||||
|
||||
for (habitIndex, habit) in sortedHabits.enumerated() {
|
||||
if habit.sortIndex != habitIndex {
|
||||
habit.sortIndex = habitIndex
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure only one active in-progress arc per ritual (keep the newest by start date).
|
||||
if inProgressActiveArcs.count > 1 {
|
||||
let sortedInProgress = inProgressActiveArcs.sorted { lhs, rhs in
|
||||
if lhs.startDate != rhs.startDate {
|
||||
return lhs.startDate > rhs.startDate
|
||||
}
|
||||
return lhs.arcNumber > rhs.arcNumber
|
||||
}
|
||||
|
||||
for arc in sortedInProgress.dropFirst() where arc.isActive {
|
||||
arc.isActive = false
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return didChange
|
||||
}
|
||||
|
||||
func reloadData() {
|
||||
do {
|
||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||
updateDerivedData()
|
||||
invalidateAnalyticsCache()
|
||||
scheduleReminderUpdate()
|
||||
dataRefreshVersion &+= 1
|
||||
} catch {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func didSaveAndReloadData() {
|
||||
// Widget timeline reloads can destabilize test hosts; skip in tests.
|
||||
if !isRunningTests {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
// Trigger a UI refresh for observation-based views
|
||||
analyticsNeedsRefresh = true
|
||||
insightCardsNeedRefresh = true
|
||||
}
|
||||
|
||||
func handleSaveAndReloadError(_ error: Error) {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
func updateDerivedData() {
|
||||
let currentDate = now()
|
||||
currentRituals = rituals
|
||||
.filter { $0.activeArc(on: currentDate) != nil }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.timeOfDay != rhs.timeOfDay {
|
||||
return lhs.timeOfDay < rhs.timeOfDay
|
||||
}
|
||||
return lhs.sortIndex < rhs.sortIndex
|
||||
}
|
||||
pastRituals = rituals
|
||||
.filter { $0.activeArc(on: currentDate) == nil }
|
||||
.sorted { ($0.lastCompletedDate ?? .distantPast) > ($1.lastCompletedDate ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
117
Andromida/App/State/RitualStore+Debug.swift
Normal file
117
Andromida/App/State/RitualStore+Debug.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Bedrock
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Debug / Demo Data
|
||||
|
||||
#if DEBUG
|
||||
/// Preloads 6 months of random completion data for testing the history view.
|
||||
func preloadDemoData() {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
|
||||
// Go back 6 months
|
||||
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
||||
|
||||
// If no rituals exist, nothing to preload
|
||||
guard !rituals.isEmpty else { return }
|
||||
|
||||
// Update each ritual's arcs to cover a longer period
|
||||
for ritual in rituals {
|
||||
// For each arc (active or not), extend it to cover the demo period
|
||||
for arc in ritual.arcs ?? [] {
|
||||
// Set the arc to start 6 months ago and be active
|
||||
arc.startDate = sixMonthsAgo
|
||||
arc.endDate = calendar.date(byAdding: .day, value: 180 + 28 - 1, to: sixMonthsAgo) ?? today
|
||||
arc.isActive = true
|
||||
}
|
||||
|
||||
// If no arcs exist, create one
|
||||
if (ritual.arcs ?? []).isEmpty {
|
||||
let demoHabits = [
|
||||
ArcHabit(title: "Demo Habit 1", symbolName: "star.fill"),
|
||||
ArcHabit(title: "Demo Habit 2", symbolName: "heart.fill")
|
||||
]
|
||||
let demoArc = RitualArc(
|
||||
startDate: sixMonthsAgo,
|
||||
durationDays: 180 + 28,
|
||||
arcNumber: 1,
|
||||
isActive: true,
|
||||
habits: demoHabits
|
||||
)
|
||||
var arcs = ritual.arcs ?? []
|
||||
arcs.append(demoArc)
|
||||
ritual.arcs = arcs
|
||||
}
|
||||
}
|
||||
|
||||
// Generate completions for each day from 6 months ago to yesterday
|
||||
var currentDate = sixMonthsAgo
|
||||
|
||||
while currentDate < today {
|
||||
let dayID = dayIdentifier(for: currentDate)
|
||||
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs ?? [] {
|
||||
// Only generate completions if the arc covers this date
|
||||
guard arc.contains(date: currentDate) else { continue }
|
||||
|
||||
for habit in arc.habits ?? [] {
|
||||
// Random completion with ~70% average success rate
|
||||
let threshold = Double.random(in: 0.5...0.9)
|
||||
let shouldComplete = Double.random(in: 0...1) < threshold
|
||||
|
||||
if shouldComplete && !habit.completedDayIDs.contains(dayID) {
|
||||
habit.completedDayIDs.append(dayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
|
||||
}
|
||||
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Clears all completion data (for testing).
|
||||
func clearAllCompletions() {
|
||||
for ritual in rituals {
|
||||
for arc in ritual.arcs ?? [] {
|
||||
for habit in arc.habits ?? [] {
|
||||
habit.completedDayIDs.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Simulates arc completion by setting the first active arc's end date to yesterday.
|
||||
/// This triggers the renewal workflow when the user navigates to the Today tab.
|
||||
func simulateArcCompletion() {
|
||||
// Find the first ritual with an active arc
|
||||
guard let ritual = currentRituals.first,
|
||||
let arc = ritual.activeArc(on: now()) else {
|
||||
Design.debugLog("No active arcs to complete")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the end date to yesterday so the arc appears completed
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now())) ?? now()
|
||||
arc.endDate = yesterday
|
||||
|
||||
// Also backdate the start date so the arc looks like it ran for a reasonable period
|
||||
let arcDuration = arc.durationDays
|
||||
arc.startDate = calendar.date(byAdding: .day, value: -arcDuration, to: yesterday) ?? yesterday
|
||||
|
||||
saveAndReload()
|
||||
|
||||
// Trigger the completion check - this will set ritualNeedingRenewal
|
||||
checkForCompletedArcs()
|
||||
|
||||
Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
38
Andromida/App/State/RitualStore+InsightPreferences.swift
Normal file
38
Andromida/App/State/RitualStore+InsightPreferences.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Insight Card Order
|
||||
|
||||
private static let insightCardOrderKey = "insightCardOrder"
|
||||
|
||||
/// The user's preferred order for insight cards
|
||||
var insightCardOrder: [InsightCardType] {
|
||||
get {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.insightCardOrderKey),
|
||||
let order = try? JSONDecoder().decode([InsightCardType].self, from: data) else {
|
||||
return InsightCardType.defaultOrder
|
||||
}
|
||||
// Ensure all card types are included (in case new ones were added)
|
||||
let missingTypes = InsightCardType.allCases.filter { !order.contains($0) }
|
||||
return order + missingTypes
|
||||
}
|
||||
set {
|
||||
if let data = try? JSONEncoder().encode(newValue) {
|
||||
UserDefaults.standard.set(data, forKey: Self.insightCardOrderKey)
|
||||
}
|
||||
insightCardsNeedRefresh = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders insight cards by moving a card from one position to another
|
||||
func reorderInsightCards(from sourceIndex: Int, to destinationIndex: Int) {
|
||||
var order = insightCardOrder
|
||||
guard sourceIndex >= 0 && sourceIndex < order.count else { return }
|
||||
guard destinationIndex >= 0 && destinationIndex < order.count else { return }
|
||||
let item = order.remove(at: sourceIndex)
|
||||
order.insert(item, at: destinationIndex)
|
||||
insightCardOrder = order
|
||||
}
|
||||
|
||||
}
|
||||
310
Andromida/App/State/RitualStore+Insights.swift
Normal file
310
Andromida/App/State/RitualStore+Insights.swift
Normal file
@ -0,0 +1,310 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Streak Tracking
|
||||
|
||||
/// Returns the set of all day IDs that had 100% completion across all active arcs for that day
|
||||
func perfectDays() -> Set<String> {
|
||||
refreshAnalyticsCacheIfNeeded()
|
||||
return cachedPerfectDayIDs
|
||||
}
|
||||
|
||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||
func currentStreak() -> Int {
|
||||
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar, currentDate: now())
|
||||
}
|
||||
|
||||
/// Calculates the longest streak of consecutive perfect days
|
||||
func longestStreak() -> Int {
|
||||
let perfect = perfectDays()
|
||||
guard !perfect.isEmpty else { return 0 }
|
||||
|
||||
// Sort the perfect days chronologically
|
||||
let sortedDates = perfect.compactMap { dayID -> Date? in
|
||||
dayFormatter.date(from: dayID)
|
||||
}.sorted()
|
||||
|
||||
guard !sortedDates.isEmpty else { return 0 }
|
||||
|
||||
var longest = 1
|
||||
var current = 1
|
||||
|
||||
for i in 1..<sortedDates.count {
|
||||
let prev = sortedDates[i - 1]
|
||||
let curr = sortedDates[i]
|
||||
|
||||
if let nextDay = calendar.date(byAdding: .day, value: 1, to: prev),
|
||||
calendar.isDate(nextDay, inSameDayAs: curr) {
|
||||
current += 1
|
||||
longest = max(longest, current)
|
||||
} else {
|
||||
current = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
/// Returns completion data for the last 7 days for a trend chart
|
||||
func weeklyTrendData() -> [TrendDataPoint] {
|
||||
let today = calendar.startOfDay(for: now())
|
||||
let shortWeekdayFormatter = DateFormatter()
|
||||
shortWeekdayFormatter.calendar = calendar
|
||||
shortWeekdayFormatter.dateFormat = "EEE"
|
||||
|
||||
var dataPoints: [TrendDataPoint] = []
|
||||
|
||||
for daysAgo in (0..<7).reversed() {
|
||||
guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { continue }
|
||||
|
||||
let rate = completionRate(for: date)
|
||||
|
||||
dataPoints.append(TrendDataPoint(
|
||||
date: date,
|
||||
value: rate,
|
||||
label: shortWeekdayFormatter.string(from: date)
|
||||
))
|
||||
}
|
||||
|
||||
return dataPoints
|
||||
}
|
||||
|
||||
/// Returns the average completion rate for the last 7 days
|
||||
func weeklyAverageCompletion() -> Int {
|
||||
let trend = weeklyTrendData()
|
||||
guard !trend.isEmpty else { return 0 }
|
||||
let sum = trend.reduce(0.0) { $0 + $1.value }
|
||||
return Int((sum / Double(trend.count)) * 100)
|
||||
}
|
||||
|
||||
/// Returns a breakdown showing how Days Active is calculated
|
||||
private func daysActiveBreakdown() -> [BreakdownItem] {
|
||||
let activeDates = datesWithActivity()
|
||||
let totalDays = activeDates.count
|
||||
|
||||
// Get first and last active dates
|
||||
let sortedDates = activeDates.sorted()
|
||||
|
||||
var breakdown: [BreakdownItem] = []
|
||||
|
||||
// Total check-ins
|
||||
let totalCheckIns = rituals
|
||||
.flatMap { $0.arcs ?? [] }
|
||||
.flatMap { $0.habits ?? [] }
|
||||
.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
breakdown.append(BreakdownItem(
|
||||
label: String(localized: "Total check-ins"),
|
||||
value: "\(totalCheckIns)"
|
||||
))
|
||||
|
||||
// Unique days
|
||||
breakdown.append(BreakdownItem(
|
||||
label: String(localized: "Unique days with activity"),
|
||||
value: "\(totalDays)"
|
||||
))
|
||||
|
||||
// Date range
|
||||
if let first = sortedDates.first, let last = sortedDates.last {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
|
||||
breakdown.append(BreakdownItem(
|
||||
label: String(localized: "First check-in"),
|
||||
value: dateFormatter.string(from: first)
|
||||
))
|
||||
|
||||
if first != last {
|
||||
breakdown.append(BreakdownItem(
|
||||
label: String(localized: "Most recent"),
|
||||
value: dateFormatter.string(from: last)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Per-ritual breakdown
|
||||
for ritual in rituals {
|
||||
let ritualDays = Set((ritual.arcs ?? []).flatMap { $0.habits ?? [] }.flatMap { $0.completedDayIDs }).count
|
||||
breakdown.append(BreakdownItem(
|
||||
label: ritual.title,
|
||||
value: String(localized: "\(ritualDays) days")
|
||||
))
|
||||
}
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
func insightCards() -> [InsightCard] {
|
||||
refreshInsightCardsIfNeeded()
|
||||
return cachedInsightCards
|
||||
}
|
||||
|
||||
func refreshInsightCardsIfNeeded() {
|
||||
guard insightCardsNeedRefresh else { return }
|
||||
cachedInsightCards = computeInsightCards()
|
||||
insightCardsNeedRefresh = false
|
||||
}
|
||||
|
||||
private func computeInsightCards() -> [InsightCard] {
|
||||
let currentDate = now()
|
||||
let inProgressRituals = currentRituals.filter { ritual in
|
||||
ritual.activeArc(on: currentDate) != nil
|
||||
}
|
||||
|
||||
// Only count habits from active arcs for today's stats
|
||||
let activeHabitsToday = habitsInProgress(on: currentDate)
|
||||
let totalHabits = activeHabitsToday.count
|
||||
let completedToday = activeHabitsToday.filter { isHabitCompletedToday($0) }.count
|
||||
let completionRateValue = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||
|
||||
// Days active = unique calendar days with at least one check-in
|
||||
let daysActiveCount = datesWithActivity().count
|
||||
|
||||
// Count rituals with active arcs
|
||||
let activeRitualCount = inProgressRituals.count
|
||||
|
||||
// Build per-ritual progress breakdown
|
||||
let habitsBreakdown = inProgressRituals.map { ritual in
|
||||
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||
return BreakdownItem(
|
||||
label: ritual.title,
|
||||
value: "\(completed) of \(ritual.habits.count)"
|
||||
)
|
||||
}
|
||||
|
||||
// Streak tracking
|
||||
let current = currentStreak()
|
||||
let longest = longestStreak()
|
||||
let streakBreakdown = [
|
||||
BreakdownItem(label: String(localized: "Current streak"), value: "\(current) days"),
|
||||
BreakdownItem(label: String(localized: "Longest streak"), value: "\(longest) days")
|
||||
]
|
||||
|
||||
// Weekly trend
|
||||
let trendData = weeklyTrendData()
|
||||
let trendBreakdown = trendData.map { point in
|
||||
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
|
||||
}
|
||||
|
||||
// 7-day average
|
||||
let weeklyAvg = weeklyAverageCompletion()
|
||||
|
||||
// Total habits completed all-time
|
||||
let totalHabitsAllTime = rituals
|
||||
.flatMap { $0.arcs ?? [] }
|
||||
.flatMap { $0.habits ?? [] }
|
||||
.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
|
||||
// Best ritual by completion rate
|
||||
let bestRitualInfo: (title: String, rate: Int)? = {
|
||||
var best: (title: String, rate: Int)?
|
||||
for ritual in inProgressRituals {
|
||||
guard let arc = ritual.activeArc(on: currentDate) else { continue }
|
||||
let habits = arc.habits ?? []
|
||||
guard !habits.isEmpty else { continue }
|
||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
let possibleCheckIns = habits.count * arc.dayIndex(for: currentDate)
|
||||
guard possibleCheckIns > 0 else { continue }
|
||||
let rate = Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100)
|
||||
if best.map({ rate > $0.rate }) ?? true {
|
||||
best = (ritual.title, rate)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}()
|
||||
|
||||
// Build cards dictionary by type
|
||||
let cardsByType: [InsightCardType: InsightCard] = [
|
||||
.active: InsightCard(
|
||||
type: .active,
|
||||
title: String(localized: "Active"),
|
||||
value: "\(activeRitualCount)",
|
||||
caption: String(localized: "In progress now"),
|
||||
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
|
||||
symbolName: "sparkles",
|
||||
breakdown: inProgressRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||
),
|
||||
.streak: InsightCard(
|
||||
type: .streak,
|
||||
title: String(localized: "Streak"),
|
||||
value: "\(current)",
|
||||
caption: String(localized: "Consecutive perfect days"),
|
||||
explanation: String(localized: "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!"),
|
||||
symbolName: "flame.fill",
|
||||
breakdown: streakBreakdown
|
||||
),
|
||||
.habitsToday: InsightCard(
|
||||
type: .habitsToday,
|
||||
title: String(localized: "Habits today"),
|
||||
value: "\(completedToday)",
|
||||
caption: String(localized: "Completed today"),
|
||||
explanation: String(localized: "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals."),
|
||||
symbolName: "checkmark.circle.fill",
|
||||
breakdown: habitsBreakdown
|
||||
),
|
||||
.completion: InsightCard(
|
||||
type: .completion,
|
||||
title: String(localized: "Completion"),
|
||||
value: "\(completionRateValue)%",
|
||||
caption: String(localized: "Today's progress"),
|
||||
explanation: String(localized: "Your completion percentage for today across all rituals. The chart shows your last 7 days—this helps you spot patterns and stay consistent."),
|
||||
symbolName: "chart.bar.fill",
|
||||
breakdown: trendBreakdown,
|
||||
trendData: trendData
|
||||
),
|
||||
.daysActive: InsightCard(
|
||||
type: .daysActive,
|
||||
title: String(localized: "Days Active"),
|
||||
value: "\(daysActiveCount)",
|
||||
caption: String(localized: "Days you checked in"),
|
||||
explanation: String(localized: "This counts every unique calendar day where you completed at least one habit. It's calculated by scanning all your habit check-ins across all rituals and counting the distinct days. For example, if you checked in on Monday, skipped Tuesday, then checked in Wednesday and Thursday, your Days Active would be 3."),
|
||||
symbolName: "calendar",
|
||||
breakdown: daysActiveBreakdown()
|
||||
),
|
||||
.sevenDayAvg: InsightCard(
|
||||
type: .sevenDayAvg,
|
||||
title: String(localized: "7-Day Avg"),
|
||||
value: "\(weeklyAvg)%",
|
||||
caption: String(localized: "Weekly average"),
|
||||
explanation: String(localized: "Your average completion rate over the last 7 days. This smooths out daily fluctuations and shows your typical consistency."),
|
||||
symbolName: "chart.line.uptrend.xyaxis",
|
||||
breakdown: trendBreakdown,
|
||||
trendData: trendData
|
||||
),
|
||||
.totalCheckIns: InsightCard(
|
||||
type: .totalCheckIns,
|
||||
title: String(localized: "Total Check-ins"),
|
||||
value: "\(totalHabitsAllTime)",
|
||||
caption: String(localized: "All-time habits completed"),
|
||||
explanation: String(localized: "The total number of habit check-ins you've made since you started using Rituals. Every check-in counts toward building lasting change."),
|
||||
symbolName: "checkmark.seal.fill"
|
||||
),
|
||||
.bestRitual: {
|
||||
if let best = bestRitualInfo {
|
||||
return InsightCard(
|
||||
type: .bestRitual,
|
||||
title: String(localized: "Best Ritual"),
|
||||
value: "\(best.rate)%",
|
||||
caption: best.title,
|
||||
explanation: String(localized: "Your highest-performing ritual by completion rate in the current arc. Keep it up!"),
|
||||
symbolName: "star.fill"
|
||||
)
|
||||
} else {
|
||||
return InsightCard(
|
||||
type: .bestRitual,
|
||||
title: String(localized: "Best Ritual"),
|
||||
value: "—",
|
||||
caption: String(localized: "No active rituals"),
|
||||
explanation: String(localized: "Start a ritual to see which one you complete most consistently."),
|
||||
symbolName: "star.fill"
|
||||
)
|
||||
}
|
||||
}()
|
||||
]
|
||||
|
||||
// Return cards in user's preferred order
|
||||
let order = insightCardOrder
|
||||
return order.compactMap { cardsByType[$0] }
|
||||
}
|
||||
|
||||
}
|
||||
71
Andromida/App/State/RitualStore+Progress.swift
Normal file
71
Andromida/App/State/RitualStore+Progress.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import Bedrock
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Progress And Completion
|
||||
|
||||
func ritualProgress(for ritual: Ritual) -> Double {
|
||||
let habits = ritual.habits
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
let completed = habits.filter { isHabitCompletedToday($0) }.count
|
||||
return Double(completed) / Double(habits.count)
|
||||
}
|
||||
|
||||
func habits(for ritual: Ritual) -> [ArcHabit] {
|
||||
ritual.habits
|
||||
}
|
||||
|
||||
func isHabitCompletedToday(_ habit: ArcHabit) -> Bool {
|
||||
let dayID = dayIdentifier(for: now())
|
||||
return habit.completedDayIDs.contains(dayID)
|
||||
}
|
||||
|
||||
func toggleHabitCompletion(_ habit: ArcHabit) {
|
||||
toggleHabitCompletion(habit, date: now())
|
||||
}
|
||||
|
||||
func toggleHabitCompletion(_ habit: ArcHabit, date: Date) {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let wasCompleted = habit.completedDayIDs.contains(dayID)
|
||||
|
||||
if wasCompleted {
|
||||
habit.completedDayIDs.removeAll { $0 == dayID }
|
||||
} else {
|
||||
habit.completedDayIDs.append(dayID)
|
||||
// Play feedback on check-in (not on uncheck)
|
||||
if settingsStore.hapticsEnabled {
|
||||
SoundManager.shared.playHaptic(.success)
|
||||
}
|
||||
if settingsStore.soundEnabled {
|
||||
SoundManager.shared.playSystemSound(SystemSound.success)
|
||||
}
|
||||
}
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
func ritualDayIndex(for ritual: Ritual) -> Int {
|
||||
let currentDate = now()
|
||||
guard let arc = ritual.activeArc(on: currentDate) else { return 0 }
|
||||
return arc.dayIndex(for: currentDate)
|
||||
}
|
||||
|
||||
func ritualDayLabel(for ritual: Ritual) -> String {
|
||||
let format = String(localized: "Day %lld of %lld")
|
||||
return String.localizedStringWithFormat(
|
||||
format,
|
||||
ritualDayIndex(for: ritual),
|
||||
ritual.durationDays
|
||||
)
|
||||
}
|
||||
|
||||
func completionSummary(for ritual: Ritual) -> String {
|
||||
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||
let format = String(localized: "%lld of %lld habits complete")
|
||||
return String.localizedStringWithFormat(
|
||||
format,
|
||||
completed,
|
||||
ritual.habits.count
|
||||
)
|
||||
}
|
||||
}
|
||||
228
Andromida/App/State/RitualStore+Rituals.swift
Normal file
228
Andromida/App/State/RitualStore+Rituals.swift
Normal file
@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Bedrock
|
||||
|
||||
@MainActor
|
||||
extension RitualStore {
|
||||
// MARK: - Ritual Management
|
||||
|
||||
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
||||
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
|
||||
func ritualsForToday() -> [Ritual] {
|
||||
let today = now()
|
||||
let timeOfDay = effectiveTimeOfDay()
|
||||
|
||||
return currentRituals.filter { ritual in
|
||||
guard ritual.activeArc(on: today) != nil else {
|
||||
return false
|
||||
}
|
||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups current rituals by time of day for display
|
||||
func currentRitualsGroupedByTime() -> [(timeOfDay: TimeOfDay, rituals: [Ritual])] {
|
||||
let grouped = Dictionary(grouping: currentRituals) { $0.timeOfDay }
|
||||
return TimeOfDay.allCases
|
||||
.compactMap { time in
|
||||
guard let rituals = grouped[time], !rituals.isEmpty else { return nil }
|
||||
return (timeOfDay: time, rituals: rituals)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ritual Editing
|
||||
|
||||
func createQuickRitual() {
|
||||
let defaultDuration = 28
|
||||
let habits = [
|
||||
ArcHabit(title: String(localized: "Hydrate"), symbolName: "drop.fill", sortIndex: 0),
|
||||
ArcHabit(title: String(localized: "Move"), symbolName: "figure.walk", sortIndex: 1),
|
||||
ArcHabit(title: String(localized: "Reflect"), symbolName: "pencil.and.list.clipboard", sortIndex: 2)
|
||||
]
|
||||
_ = createRitualWithInitialArc(
|
||||
title: String(localized: "Custom Ritual"),
|
||||
theme: String(localized: "Your next chapter"),
|
||||
defaultDurationDays: defaultDuration,
|
||||
notes: String(localized: "A fresh ritual created from your focus today."),
|
||||
timeOfDay: .anytime,
|
||||
iconName: "sparkles",
|
||||
category: "",
|
||||
sortIndex: rituals.count,
|
||||
durationDays: defaultDuration,
|
||||
habits: habits
|
||||
)
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
private func createRitualWithInitialArc(
|
||||
title: String,
|
||||
theme: String,
|
||||
defaultDurationDays: Int,
|
||||
notes: String,
|
||||
timeOfDay: TimeOfDay,
|
||||
iconName: String,
|
||||
category: String,
|
||||
sortIndex: Int,
|
||||
durationDays: Int,
|
||||
habits: [ArcHabit]
|
||||
) -> Ritual {
|
||||
let ritual = Ritual(
|
||||
title: title,
|
||||
theme: theme,
|
||||
defaultDurationDays: defaultDurationDays,
|
||||
notes: notes,
|
||||
timeOfDay: timeOfDay,
|
||||
iconName: iconName,
|
||||
category: category,
|
||||
sortIndex: sortIndex,
|
||||
arcs: []
|
||||
)
|
||||
modelContext.insert(ritual)
|
||||
|
||||
let indexedHabits = habits.enumerated().map { index, habit in
|
||||
habit.sortIndex = index
|
||||
return habit
|
||||
}
|
||||
for habit in indexedHabits {
|
||||
modelContext.insert(habit)
|
||||
}
|
||||
|
||||
let arc = RitualArc(
|
||||
startDate: now(),
|
||||
durationDays: durationDays,
|
||||
arcNumber: 1,
|
||||
isActive: true,
|
||||
habits: []
|
||||
)
|
||||
modelContext.insert(arc)
|
||||
arc.habits = indexedHabits
|
||||
ritual.arcs = [arc]
|
||||
|
||||
return ritual
|
||||
}
|
||||
|
||||
/// Creates a new ritual with the given properties
|
||||
func createRitual(
|
||||
title: String,
|
||||
theme: String,
|
||||
notes: String = "",
|
||||
durationDays: Int = 28,
|
||||
timeOfDay: TimeOfDay = .anytime,
|
||||
iconName: String = "sparkles",
|
||||
category: String = "",
|
||||
habits: [ArcHabit] = []
|
||||
) {
|
||||
_ = createRitualWithInitialArc(
|
||||
title: title,
|
||||
theme: theme,
|
||||
defaultDurationDays: durationDays,
|
||||
notes: notes,
|
||||
timeOfDay: timeOfDay,
|
||||
iconName: iconName,
|
||||
category: category,
|
||||
sortIndex: rituals.count,
|
||||
durationDays: durationDays,
|
||||
habits: habits
|
||||
)
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Creates a ritual from a preset template
|
||||
func createRitualFromPreset(_ preset: RitualPreset) {
|
||||
let habits = preset.habits.enumerated().map { index, habitPreset in
|
||||
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
|
||||
}
|
||||
createRitual(
|
||||
title: preset.title,
|
||||
theme: preset.theme,
|
||||
notes: preset.notes,
|
||||
durationDays: preset.durationDays,
|
||||
timeOfDay: preset.timeOfDay,
|
||||
iconName: preset.iconName,
|
||||
category: preset.category,
|
||||
habits: habits
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a ritual from a preset template and returns it.
|
||||
/// Used during onboarding to immediately show the created ritual.
|
||||
@discardableResult
|
||||
func createRitual(from preset: RitualPreset) -> Ritual {
|
||||
let habits = preset.habits.enumerated().map { index, habitPreset in
|
||||
ArcHabit(title: habitPreset.title, symbolName: habitPreset.symbolName, sortIndex: index)
|
||||
}
|
||||
|
||||
let ritual = createRitualWithInitialArc(
|
||||
title: preset.title,
|
||||
theme: preset.theme,
|
||||
defaultDurationDays: preset.durationDays,
|
||||
notes: preset.notes,
|
||||
timeOfDay: preset.timeOfDay,
|
||||
iconName: preset.iconName,
|
||||
category: preset.category,
|
||||
sortIndex: rituals.count,
|
||||
durationDays: preset.durationDays,
|
||||
habits: habits
|
||||
)
|
||||
saveAndReload()
|
||||
return ritual
|
||||
}
|
||||
|
||||
/// Updates an existing ritual's properties
|
||||
func updateRitual(
|
||||
_ ritual: Ritual,
|
||||
title: String,
|
||||
theme: String,
|
||||
notes: String,
|
||||
durationDays: Int,
|
||||
timeOfDay: TimeOfDay,
|
||||
iconName: String,
|
||||
category: String
|
||||
) {
|
||||
ritual.title = title
|
||||
ritual.theme = theme
|
||||
ritual.notes = notes
|
||||
ritual.defaultDurationDays = durationDays
|
||||
ritual.timeOfDay = timeOfDay
|
||||
ritual.iconName = iconName
|
||||
ritual.category = category
|
||||
|
||||
// Also update the current arc's end date if duration changed
|
||||
if let currentArc = ritual.activeArc(on: now()), currentArc.durationDays != durationDays {
|
||||
let newEndDate = calendar.date(byAdding: .day, value: durationDays - 1, to: currentArc.startDate) ?? currentArc.endDate
|
||||
currentArc.endDate = newEndDate
|
||||
}
|
||||
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Permanently deletes a ritual and all its history
|
||||
func deleteRitual(_ ritual: Ritual) {
|
||||
modelContext.delete(ritual)
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Adds a habit to the current arc of a ritual
|
||||
func addHabit(to ritual: Ritual, title: String, symbolName: String) {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return }
|
||||
let habits = arc.habits ?? []
|
||||
let nextSortIndex = habits.map(\.sortIndex).max().map { $0 + 1 } ?? 0
|
||||
let habit = ArcHabit(title: title, symbolName: symbolName, sortIndex: nextSortIndex)
|
||||
var updatedHabits = habits
|
||||
updatedHabits.append(habit)
|
||||
arc.habits = updatedHabits
|
||||
saveAndReload()
|
||||
}
|
||||
|
||||
/// Removes a habit from the current arc of a ritual
|
||||
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
|
||||
guard let arc = ritual.activeArc(on: now()) else { return }
|
||||
var habits = arc.habits ?? []
|
||||
habits.removeAll { $0.id == habit.id }
|
||||
arc.habits = habits
|
||||
modelContext.delete(habit)
|
||||
saveAndReload()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,15 +9,14 @@ struct RootView: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var selectedTab: RootTab
|
||||
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
||||
@State private var cloudKitFallbackRefreshTask: Task<Void, Never>?
|
||||
@State private var aggressiveCloudKitRefreshTasks: [Task<Void, Never>] = []
|
||||
@State private var macCloudKitPulseTask: Task<Void, Never>?
|
||||
@State private var cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator(
|
||||
configuration: .init(logIdentifier: "RootView.CloudKitSync")
|
||||
)
|
||||
@State private var isForegroundRefreshing = false
|
||||
@State private var isResumingFromBackground = false
|
||||
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
|
||||
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
|
||||
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
|
||||
private let macCloudKitPulseIntervalSeconds: TimeInterval = 5
|
||||
|
||||
/// The available tabs in the app.
|
||||
enum RootTab: Hashable {
|
||||
@ -113,7 +112,11 @@ struct RootView: View {
|
||||
}
|
||||
.onAppear {
|
||||
if scenePhase == .active {
|
||||
startMacCloudKitPulseLoopIfNeeded()
|
||||
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
||||
store: store,
|
||||
isAppActive: { scenePhase == .active },
|
||||
refresh: { store.refresh() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
@ -141,16 +144,15 @@ struct RootView: View {
|
||||
showOverlay: useDebugOverlay,
|
||||
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
|
||||
)
|
||||
scheduleAggressiveCloudKitRefreshes()
|
||||
scheduleCloudKitFallbackRefresh()
|
||||
startMacCloudKitPulseLoopIfNeeded()
|
||||
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
||||
store: store,
|
||||
isAppActive: { scenePhase == .active },
|
||||
refresh: { store.refresh() }
|
||||
)
|
||||
} else if newPhase == .background {
|
||||
// Prepare for next resume
|
||||
isResumingFromBackground = true
|
||||
cloudKitFallbackRefreshTask?.cancel()
|
||||
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
|
||||
aggressiveCloudKitRefreshTasks.removeAll()
|
||||
stopMacCloudKitPulseLoop()
|
||||
cloudKitSceneSyncCoordinator.handleDidEnterBackground()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
@ -166,9 +168,7 @@ struct RootView: View {
|
||||
handleURL(url)
|
||||
}
|
||||
.onDisappear {
|
||||
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
|
||||
aggressiveCloudKitRefreshTasks.removeAll()
|
||||
stopMacCloudKitPulseLoop()
|
||||
cloudKitSceneSyncCoordinator.handleViewDisappear()
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,54 +222,6 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a one-shot fallback refresh only if no remote change arrived after activation.
|
||||
private func scheduleCloudKitFallbackRefresh() {
|
||||
let activationDate = Date()
|
||||
cloudKitFallbackRefreshTask?.cancel()
|
||||
cloudKitFallbackRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled else { return }
|
||||
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
|
||||
store.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggressive delayed refreshes to catch late CloudKit merge delivery.
|
||||
/// This mirrors the previous high-reliability behavior used before Bedrock extraction.
|
||||
private func scheduleAggressiveCloudKitRefreshes() {
|
||||
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
|
||||
aggressiveCloudKitRefreshTasks.removeAll()
|
||||
|
||||
for delay in [5.0, 10.0] {
|
||||
let task = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
guard scenePhase == .active else { return }
|
||||
store.refresh()
|
||||
}
|
||||
aggressiveCloudKitRefreshTasks.append(task)
|
||||
}
|
||||
}
|
||||
|
||||
private func startMacCloudKitPulseLoopIfNeeded() {
|
||||
guard store.supportsMacCloudKitImportPulseFallback else { return }
|
||||
|
||||
macCloudKitPulseTask?.cancel()
|
||||
macCloudKitPulseTask = Task { @MainActor in
|
||||
Design.debugLog("RootView.CloudKitSync: starting macOS pulse loop interval=\(macCloudKitPulseIntervalSeconds)s")
|
||||
while !Task.isCancelled {
|
||||
guard scenePhase == .active else { return }
|
||||
store.forceCloudKitImportPulse(reason: "active_loop")
|
||||
try? await Task.sleep(for: .seconds(macCloudKitPulseIntervalSeconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMacCloudKitPulseLoop() {
|
||||
macCloudKitPulseTask?.cancel()
|
||||
macCloudKitPulseTask = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@ -46,6 +46,7 @@ Entitlements should reference variables, not hard-coded values, where possible.
|
||||
- Rebuild long-lived contexts only when safe (`hasChanges == false`) to avoid dropping unsaved local edits.
|
||||
- Implement protocol reload hook (`reloadData`) to run your store-specific fetch step.
|
||||
- Prefer Bedrock `saveAndReload()` + protocol hooks (`didSaveAndReloadData`, `handleSaveAndReloadError`) instead of local save wrappers.
|
||||
- Prefer Bedrock `SwiftDataCloudKitSceneSyncCoordinator` for active/background fallback scheduling instead of app-local task orchestration.
|
||||
- Keep iOS-on-Mac pulsing loop in the root scene lifecycle (`active` only, cancel on `background`).
|
||||
- Keep a foreground fallback refresh as a safety net; gate it with Bedrock `hasReceivedRemoteChange(since:)`.
|
||||
- Emit structured logs for remote sync events (event count + timestamp) for debugging.
|
||||
@ -84,6 +85,7 @@ Before shipping any new SwiftData+CloudKit app:
|
||||
- [ ] Remote change observer uses Bedrock `startObservingCloudKitRemoteChanges(...)`
|
||||
- [ ] Store conforms to Bedrock `SwiftDataCloudKitStore`
|
||||
- [ ] Save flows use Bedrock `saveAndReload()` and protocol hooks (no local wrapper duplication)
|
||||
- [ ] Scene-phase fallback scheduling uses Bedrock `SwiftDataCloudKitSceneSyncCoordinator`
|
||||
- [ ] Foreground fallback is gated by Bedrock `hasReceivedRemoteChange(since:)`
|
||||
- [ ] UI has deterministic invalidation on remote reload
|
||||
- [ ] Two-device batch-update test passes without manual refresh
|
||||
|
||||
@ -126,9 +126,9 @@ import SwiftUI
|
||||
struct RootView: View {
|
||||
@Bindable var store: AppDataStore
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var fallbackRefreshTask: Task<Void, Never>?
|
||||
@State private var macPulseTask: Task<Void, Never>?
|
||||
private let macPulseIntervalSeconds: TimeInterval = 5
|
||||
@State private var cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator(
|
||||
configuration: .init(logIdentifier: "RootView.CloudKitSync")
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
// Ensure body observes remote reload increments.
|
||||
@ -137,47 +137,25 @@ struct RootView: View {
|
||||
ContentView(store: store)
|
||||
.onAppear {
|
||||
if scenePhase == .active {
|
||||
startMacPulseLoopIfNeeded()
|
||||
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
||||
store: store,
|
||||
isAppActive: { scenePhase == .active },
|
||||
refresh: { store.refresh() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
startMacPulseLoopIfNeeded()
|
||||
scheduleCloudKitFallbackRefresh()
|
||||
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
|
||||
store: store,
|
||||
isAppActive: { scenePhase == .active },
|
||||
refresh: { store.refresh() }
|
||||
)
|
||||
} else if newPhase == .background {
|
||||
fallbackRefreshTask?.cancel()
|
||||
stopMacPulseLoop()
|
||||
cloudKitSceneSyncCoordinator.handleDidEnterBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCloudKitFallbackRefresh() {
|
||||
let activationDate = Date()
|
||||
fallbackRefreshTask?.cancel()
|
||||
fallbackRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled else { return }
|
||||
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
|
||||
store.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func startMacPulseLoopIfNeeded() {
|
||||
guard store.supportsMacCloudKitImportPulseFallback else { return }
|
||||
macPulseTask?.cancel()
|
||||
macPulseTask = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
guard scenePhase == .active else { return }
|
||||
store.forceCloudKitImportPulse(reason: "active_loop")
|
||||
try? await Task.sleep(for: .seconds(macPulseIntervalSeconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMacPulseLoop() {
|
||||
macPulseTask?.cancel()
|
||||
macPulseTask = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user