Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 17:11:35 -06:00
parent 2eb2abfba8
commit dd89905b29
12 changed files with 133 additions and 162 deletions

View File

@ -3,7 +3,9 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array/> <array>
<string>iCloud.com.mbrucedogs.Andromida</string>
</array>
<key>com.apple.developer.icloud-services</key> <key>com.apple.developer.icloud-services</key>
<array> <array>
<string>CloudKit</string> <string>CloudKit</string>

View File

@ -16,7 +16,11 @@ struct AndromidaApp: App {
init() { init() {
// Include all models in schema - Ritual, RitualArc, and ArcHabit // Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self]) let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.com.mbrucedogs.Andromida")
)
let container: ModelContainer let container: ModelContainer
do { do {
container = try ModelContainer(for: schema, configurations: [configuration]) container = try ModelContainer(for: schema, configurations: [configuration])

View File

@ -6,11 +6,11 @@ import SwiftData
/// while preserving historical data. /// while preserving historical data.
@Model @Model
final class ArcHabit { final class ArcHabit {
var id: UUID var id: UUID = UUID()
var title: String var title: String = ""
var symbolName: String var symbolName: String = ""
var goal: String var goal: String = ""
var completedDayIDs: [String] var completedDayIDs: [String] = []
@Relationship(inverse: \RitualArc.habits) @Relationship(inverse: \RitualArc.habits)
var arc: RitualArc? var arc: RitualArc?

View File

@ -73,24 +73,24 @@ enum TimeOfDay: String, Codable, CaseIterable, Comparable {
/// This allows rituals to be renewed while preserving historical accuracy. /// This allows rituals to be renewed while preserving historical accuracy.
@Model @Model
final class Ritual { final class Ritual {
var id: UUID var id: UUID = UUID()
var title: String var title: String = ""
var theme: String var theme: String = ""
var notes: String var notes: String = ""
// Default duration for new arcs // Default duration for new arcs
var defaultDurationDays: Int var defaultDurationDays: Int = 28
// Scheduling // Scheduling
var timeOfDay: TimeOfDay var timeOfDay: TimeOfDay = .anytime
// Organization // Organization
var iconName: String var iconName: String = "sparkles"
var category: String var category: String = ""
// Arcs - each arc represents a time-bound period with its own habits // Arcs - each arc represents a time-bound period with its own habits
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var arcs: [RitualArc] var arcs: [RitualArc]? = []
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@ -118,7 +118,7 @@ final class Ritual {
/// The currently active arc, if any. /// The currently active arc, if any.
var currentArc: RitualArc? { var currentArc: RitualArc? {
arcs.first { $0.isActive } arcs?.first { $0.isActive }
} }
/// Whether this ritual has an active arc in progress. /// Whether this ritual has an active arc in progress.
@ -128,7 +128,7 @@ final class Ritual {
/// All arcs sorted by start date (newest first). /// All arcs sorted by start date (newest first).
var sortedArcs: [RitualArc] { var sortedArcs: [RitualArc] {
arcs.sorted { $0.startDate > $1.startDate } (arcs ?? []).sorted { $0.startDate > $1.startDate }
} }
/// The most recent arc (active or completed). /// The most recent arc (active or completed).
@ -138,12 +138,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 (arcs ?? []).filter { !$0.isActive }.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 } (arcs ?? []).filter { !$0.isActive }
.sorted { $0.endDate > $1.endDate } .sorted { $0.endDate > $1.endDate }
.first?.endDate .first?.endDate
} }
@ -174,12 +174,12 @@ final class Ritual {
/// 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 ?? []).first { $0.contains(date: date) }
} }
/// Returns all arcs that overlap with a date range. /// Returns all arcs that overlap with a date range.
func arcs(in range: ClosedRange<Date>) -> [RitualArc] { func arcs(in range: ClosedRange<Date>) -> [RitualArc] {
arcs.filter { arc in (arcs ?? []).filter { arc in
// Arc overlaps if its range intersects with the query range // Arc overlaps if its range intersects with the query range
arc.endDate >= range.lowerBound && arc.startDate <= range.upperBound arc.endDate >= range.lowerBound && arc.startDate <= range.upperBound
} }

View File

@ -6,14 +6,14 @@ import SwiftData
/// the old arc's data remains frozen for historical accuracy. /// the old arc's data remains frozen for historical accuracy.
@Model @Model
final class RitualArc { final class RitualArc {
var id: UUID var id: UUID = UUID()
var startDate: Date var startDate: Date = Date()
var endDate: Date var endDate: Date = Date()
var arcNumber: Int var arcNumber: Int = 1
var isActive: Bool var isActive: Bool = true
@Relationship(deleteRule: .cascade) @Relationship(deleteRule: .cascade)
var habits: [ArcHabit] var habits: [ArcHabit]? = []
@Relationship(inverse: \Ritual.arcs) @Relationship(inverse: \Ritual.arcs)
var ritual: Ritual? var ritual: Ritual?
@ -88,7 +88,7 @@ final class RitualArc {
let newStartDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? Date() let newStartDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? Date()
let newDuration = durationDays ?? self.durationDays let newDuration = durationDays ?? self.durationDays
let copiedHabits = habits.map { $0.copyForNewArc() } let copiedHabits = (habits ?? []).map { $0.copyForNewArc() }
return RitualArc( return RitualArc(
startDate: newStartDate, startDate: newStartDate,

View File

@ -1,28 +0,0 @@
import Foundation
import os
enum PerformanceLogger {
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "Andromida",
category: "Performance"
)
static func measure<T>(_ name: String, _ block: () -> T) -> T {
#if DEBUG
let start = CFAbsoluteTimeGetCurrent()
let result = block()
let duration = CFAbsoluteTimeGetCurrent() - start
logger.info("\(name, privacy: .public) took \(duration, format: .fixed(precision: 3))s")
return result
#else
return block()
#endif
}
static func logDuration(_ name: String, from start: CFAbsoluteTime) {
#if DEBUG
let duration = CFAbsoluteTimeGetCurrent() - start
logger.info("\(name, privacy: .public) took \(duration, format: .fixed(precision: 3))s")
#endif
}
}

View File

@ -72,10 +72,8 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time. /// Refreshes rituals and derived state for current date/time.
func refresh() { func refresh() {
PerformanceLogger.measure("RitualStore.refresh") { reloadRituals()
reloadRituals() checkForCompletedArcs()
checkForCompletedArcs()
}
} }
func ritualProgress(for ritual: Ritual) -> Double { func ritualProgress(for ritual: Ritual) -> Double {
@ -174,12 +172,12 @@ final class RitualStore: RitualStoreProviding {
/// Returns all arcs that were active on a specific date. /// Returns all arcs that were active on a specific date.
func arcsActive(on date: Date) -> [RitualArc] { func arcsActive(on date: Date) -> [RitualArc] {
rituals.flatMap { $0.arcs }.filter { $0.contains(date: date) } rituals.flatMap { $0.arcs ?? [] }.filter { $0.contains(date: date) }
} }
/// Returns habits from all arcs that were active on a specific date. /// Returns habits from all arcs that were active on a specific date.
func habitsActive(on date: Date) -> [ArcHabit] { func habitsActive(on date: Date) -> [ArcHabit] {
arcsActive(on: date).flatMap { $0.habits } arcsActive(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).
@ -217,7 +215,7 @@ final class RitualStore: RitualStoreProviding {
let newHabits: [ArcHabit] let newHabits: [ArcHabit]
if copyHabits, let previousArc = ritual.latestArc { if copyHabits, let previousArc = ritual.latestArc {
newHabits = previousArc.habits.map { $0.copyForNewArc() } newHabits = (previousArc.habits ?? []).map { $0.copyForNewArc() }
} else { } else {
newHabits = [] newHabits = []
} }
@ -230,7 +228,9 @@ final class RitualStore: RitualStoreProviding {
habits: newHabits habits: newHabits
) )
ritual.arcs.append(newArc) var arcs = ritual.arcs ?? []
arcs.append(newArc)
ritual.arcs = arcs
saveContext() saveContext()
} }
@ -360,7 +360,10 @@ final class RitualStore: RitualStoreProviding {
var breakdown: [BreakdownItem] = [] var breakdown: [BreakdownItem] = []
// Total check-ins // Total check-ins
let totalCheckIns = rituals.flatMap { $0.arcs }.flatMap { $0.habits }.reduce(0) { $0 + $1.completedDayIDs.count } let totalCheckIns = rituals
.flatMap { $0.arcs ?? [] }
.flatMap { $0.habits ?? [] }
.reduce(0) { $0 + $1.completedDayIDs.count }
breakdown.append(BreakdownItem( breakdown.append(BreakdownItem(
label: String(localized: "Total check-ins"), label: String(localized: "Total check-ins"),
value: "\(totalCheckIns)" value: "\(totalCheckIns)"
@ -392,7 +395,7 @@ final class RitualStore: RitualStoreProviding {
// Per-ritual breakdown // Per-ritual breakdown
for ritual in rituals { for ritual in rituals {
let ritualDays = Set(ritual.arcs.flatMap { $0.habits }.flatMap { $0.completedDayIDs }).count let ritualDays = Set((ritual.arcs ?? []).flatMap { $0.habits ?? [] }.flatMap { $0.completedDayIDs }).count
breakdown.append(BreakdownItem( breakdown.append(BreakdownItem(
label: ritual.title, label: ritual.title,
value: String(localized: "\(ritualDays) days") value: String(localized: "\(ritualDays) days")
@ -409,9 +412,7 @@ final class RitualStore: RitualStoreProviding {
func refreshInsightCardsIfNeeded() { func refreshInsightCardsIfNeeded() {
guard insightCardsNeedRefresh else { return } guard insightCardsNeedRefresh else { return }
cachedInsightCards = PerformanceLogger.measure("RitualStore.insightCards") { cachedInsightCards = computeInsightCards()
computeInsightCards()
}
insightCardsNeedRefresh = false insightCardsNeedRefresh = false
} }
@ -630,14 +631,18 @@ final class RitualStore: RitualStoreProviding {
func addHabit(to ritual: Ritual, title: String, symbolName: String) { func addHabit(to ritual: Ritual, title: String, symbolName: String) {
guard let arc = ritual.currentArc else { return } guard let arc = ritual.currentArc else { return }
let habit = ArcHabit(title: title, symbolName: symbolName) let habit = ArcHabit(title: title, symbolName: symbolName)
arc.habits.append(habit) var habits = arc.habits ?? []
habits.append(habit)
arc.habits = habits
saveContext() saveContext()
} }
/// Removes a habit from the current arc of a ritual /// Removes a habit from the current arc of a ritual
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) { func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
guard let arc = ritual.currentArc else { return } guard let arc = ritual.currentArc else { return }
arc.habits.removeAll { $0.id == habit.id } var habits = arc.habits ?? []
habits.removeAll { $0.id == habit.id }
arc.habits = habits
modelContext.delete(habit) modelContext.delete(habit)
saveContext() saveContext()
} }
@ -649,15 +654,13 @@ final class RitualStore: RitualStoreProviding {
} }
private func reloadRituals() { private func reloadRituals() {
PerformanceLogger.measure("RitualStore.reloadRituals") { do {
do { rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
rituals = try modelContext.fetch(FetchDescriptor<Ritual>()) updateDerivedData()
updateDerivedData() invalidateAnalyticsCache()
invalidateAnalyticsCache() scheduleReminderUpdate()
scheduleReminderUpdate() } catch {
} catch { lastErrorMessage = error.localizedDescription
lastErrorMessage = error.localizedDescription
}
} }
} }
@ -692,23 +695,21 @@ final class RitualStore: RitualStoreProviding {
} }
private func computeDatesWithActivity() -> Set<Date> { private func computeDatesWithActivity() -> Set<Date> {
PerformanceLogger.measure("RitualStore.computeDatesWithActivity") { var dates: Set<Date> = []
var dates: Set<Date> = []
for ritual in rituals { for ritual in rituals {
for arc in ritual.arcs { for arc in ritual.arcs ?? [] {
for habit in arc.habits { for habit in arc.habits ?? [] {
for dayID in habit.completedDayIDs { for dayID in habit.completedDayIDs {
if let date = dayFormatter.date(from: dayID) { if let date = dayFormatter.date(from: dayID) {
dates.insert(calendar.startOfDay(for: date)) dates.insert(calendar.startOfDay(for: date))
}
} }
} }
} }
} }
return dates
} }
return dates
} }
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> { private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
@ -756,7 +757,7 @@ final class RitualStore: RitualStoreProviding {
if let ritual = ritual { if let ritual = ritual {
// Get habits from the arc that was active on this date // Get habits from the arc that was active on this date
if let arc = ritual.arc(for: date) { if let arc = ritual.arc(for: date) {
habits = arc.habits habits = arc.habits ?? []
} else { } else {
return 0 return 0
} }
@ -792,7 +793,7 @@ final class RitualStore: RitualStoreProviding {
if let ritual = ritual { if let ritual = ritual {
// Get habits from the arc that was active on this date // Get habits from the arc that was active on this date
if let arc = ritual.arc(for: date) { if let arc = ritual.arc(for: date) {
for habit in arc.habits { for habit in arc.habits ?? [] {
completions.append(HabitCompletion( completions.append(HabitCompletion(
habit: habit, habit: habit,
ritualTitle: ritual.title, ritualTitle: ritual.title,
@ -804,7 +805,7 @@ final class RitualStore: RitualStoreProviding {
// Get all habits from all arcs that were active on this date // Get all habits from all arcs that were active on this date
for r in rituals { for r in rituals {
if let arc = r.arc(for: date) { if let arc = r.arc(for: date) {
for habit in arc.habits { for habit in arc.habits ?? [] {
completions.append(HabitCompletion( completions.append(HabitCompletion(
habit: habit, habit: habit,
ritualTitle: r.title, ritualTitle: r.title,
@ -885,14 +886,16 @@ final class RitualStore: RitualStoreProviding {
/// Returns the current streak for a specific ritual's current arc. /// Returns the current streak for a specific ritual's current arc.
func streakForRitual(_ ritual: Ritual) -> Int { func streakForRitual(_ ritual: Ritual) -> Int {
guard let arc = ritual.currentArc, !arc.habits.isEmpty else { return 0 } guard let arc = ritual.currentArc else { return 0 }
let habits = arc.habits ?? []
guard !habits.isEmpty else { return 0 }
var streak = 0 var streak = 0
var checkDate = calendar.startOfDay(for: Date()) var checkDate = calendar.startOfDay(for: Date())
while arc.contains(date: checkDate) { while arc.contains(date: checkDate) {
let dayID = dayIdentifier(for: checkDate) let dayID = dayIdentifier(for: checkDate)
let allCompleted = arc.habits.allSatisfy { $0.completedDayIDs.contains(dayID) } let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) }
if allCompleted { if allCompleted {
streak += 1 streak += 1
@ -993,7 +996,7 @@ final class RitualStore: RitualStoreProviding {
// Update each ritual's arcs to cover a longer period // Update each ritual's arcs to cover a longer period
for ritual in rituals { for ritual in rituals {
// For each arc (active or not), extend it to cover the demo period // For each arc (active or not), extend it to cover the demo period
for arc in ritual.arcs { for arc in ritual.arcs ?? [] {
// Set the arc to start 6 months ago and be active // Set the arc to start 6 months ago and be active
arc.startDate = sixMonthsAgo arc.startDate = sixMonthsAgo
arc.endDate = calendar.date(byAdding: .day, value: 180 + 28 - 1, to: sixMonthsAgo) ?? today arc.endDate = calendar.date(byAdding: .day, value: 180 + 28 - 1, to: sixMonthsAgo) ?? today
@ -1001,7 +1004,7 @@ final class RitualStore: RitualStoreProviding {
} }
// If no arcs exist, create one // If no arcs exist, create one
if ritual.arcs.isEmpty { if (ritual.arcs ?? []).isEmpty {
let demoHabits = [ let demoHabits = [
ArcHabit(title: "Demo Habit 1", symbolName: "star.fill"), ArcHabit(title: "Demo Habit 1", symbolName: "star.fill"),
ArcHabit(title: "Demo Habit 2", symbolName: "heart.fill") ArcHabit(title: "Demo Habit 2", symbolName: "heart.fill")
@ -1013,7 +1016,9 @@ final class RitualStore: RitualStoreProviding {
isActive: true, isActive: true,
habits: demoHabits habits: demoHabits
) )
ritual.arcs.append(demoArc) var arcs = ritual.arcs ?? []
arcs.append(demoArc)
ritual.arcs = arcs
} }
} }
@ -1024,11 +1029,11 @@ final class RitualStore: RitualStoreProviding {
let dayID = dayIdentifier(for: currentDate) let dayID = dayIdentifier(for: currentDate)
for ritual in rituals { for ritual in rituals {
for arc in ritual.arcs { for arc in ritual.arcs ?? [] {
// Only generate completions if the arc covers this date // Only generate completions if the arc covers this date
guard arc.contains(date: currentDate) else { continue } guard arc.contains(date: currentDate) else { continue }
for habit in arc.habits { for habit in arc.habits ?? [] {
// Random completion with ~70% average success rate // Random completion with ~70% average success rate
let threshold = Double.random(in: 0.5...0.9) let threshold = Double.random(in: 0.5...0.9)
let shouldComplete = Double.random(in: 0...1) < threshold let shouldComplete = Double.random(in: 0...1) < threshold
@ -1049,8 +1054,8 @@ final class RitualStore: RitualStoreProviding {
/// Clears all completion data (for testing). /// Clears all completion data (for testing).
func clearAllCompletions() { func clearAllCompletions() {
for ritual in rituals { for ritual in rituals {
for arc in ritual.arcs { for arc in ritual.arcs ?? [] {
for habit in arc.habits { for habit in arc.habits ?? [] {
habit.completedDayIDs.removeAll() habit.completedDayIDs.removeAll()
} }
} }

View File

@ -88,11 +88,9 @@ struct HistoryMonthView: View {
private var dayGrid: some View { private var dayGrid: some View {
let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.xSmall), count: 7) let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.xSmall), count: 7)
let dates = daysInMonth let dates = daysInMonth
let progressValues = PerformanceLogger.measure("HistoryMonthView.progressValues.\(monthTitle)") { let progressValues = dates.map { date -> Double? in
dates.map { date -> Double? in guard let date else { return nil }
guard let date else { return nil } return date > today ? 0 : completionRate(date, selectedRitual)
return date > today ? 0 : completionRate(date, selectedRitual)
}
} }
return LazyVGrid(columns: columns, spacing: Design.Spacing.xSmall) { return LazyVGrid(columns: columns, spacing: Design.Spacing.xSmall) {

View File

@ -32,29 +32,27 @@ struct HistoryView: View {
/// - Expanded: Up to 12 months of history /// - Expanded: Up to 12 months of history
/// Months are ordered oldest first, newest last (chronological order) /// Months are ordered oldest first, newest last (chronological order)
private var months: [Date] { private var months: [Date] {
PerformanceLogger.measure("HistoryView.months") { let today = Date()
let today = Date() let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today
let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today
// Determine how far back to go // Determine how far back to go
let totalAvailableMonths = totalMonthsAvailable(from: currentMonth) let totalAvailableMonths = totalMonthsAvailable(from: currentMonth)
let effectiveMonthsToShow = min(monthsToShow, totalAvailableMonths) let effectiveMonthsToShow = min(monthsToShow, totalAvailableMonths)
let monthsBack = max(0, effectiveMonthsToShow - 1) let monthsBack = max(0, effectiveMonthsToShow - 1)
guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else { guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else {
return [currentMonth] return [currentMonth]
}
// Build list of months in chronological order (oldest first)
var result: [Date] = []
var current = startMonth
while current <= currentMonth {
result.append(current)
current = calendar.date(byAdding: .month, value: 1, to: current) ?? current
}
return result
} }
// Build list of months in chronological order (oldest first)
var result: [Date] = []
var current = startMonth
while current <= currentMonth {
result.append(current)
current = calendar.date(byAdding: .month, value: 1, to: current) ?? current
}
return result
} }
/// Check if there's more history available beyond what's shown /// Check if there's more history available beyond what's shown
@ -200,26 +198,23 @@ struct HistoryView: View {
await Task.yield() await Task.yield()
let snapshotMonths = months let snapshotMonths = months
let selected = selectedRitual let selected = selectedRitual
let cache = PerformanceLogger.measure("HistoryView.progressCache") { var result: [Date: Double] = [:]
var result: [Date: Double] = [:] let today = calendar.startOfDay(for: Date())
let today = calendar.startOfDay(for: Date())
for month in snapshotMonths { for month in snapshotMonths {
guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)), guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)),
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else {
continue continue
}
for day in range {
guard let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) else { continue }
let normalizedDate = calendar.startOfDay(for: date)
guard normalizedDate <= today else { continue }
result[normalizedDate] = store.completionRate(for: normalizedDate, ritual: selected)
}
} }
return result for day in range {
guard let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) else { continue }
let normalizedDate = calendar.startOfDay(for: date)
guard normalizedDate <= today else { continue }
result[normalizedDate] = store.completionRate(for: normalizedDate, ritual: selected)
}
} }
let cache = result
cachedProgressByDate = cache cachedProgressByDate = cache
} }
} }

View File

@ -36,11 +36,11 @@ struct RitualDetailView: View {
} }
private var hasMultipleArcs: Bool { private var hasMultipleArcs: Bool {
ritual.arcs.count > 1 (ritual.arcs ?? []).count > 1
} }
private var completedArcs: [RitualArc] { private var completedArcs: [RitualArc] {
ritual.arcs.filter { !$0.isActive }.sorted { $0.startDate > $1.startDate } (ritual.arcs ?? []).filter { !$0.isActive }.sorted { $0.startDate > $1.startDate }
} }
var body: some View { var body: some View {
@ -353,7 +353,7 @@ struct RitualDetailView: View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
SectionHeaderView( SectionHeaderView(
title: String(localized: "Arc History"), title: String(localized: "Arc History"),
subtitle: ritual.arcs.isEmpty ? nil : String(localized: "\(ritual.arcs.count) total") subtitle: (ritual.arcs ?? []).isEmpty ? nil : String(localized: "\((ritual.arcs ?? []).count) total")
) )
if completedArcs.isEmpty { if completedArcs.isEmpty {
@ -374,8 +374,9 @@ struct RitualDetailView: View {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium dateFormatter.dateStyle = .medium
let totalCheckIns = arc.habits.reduce(0) { $0 + $1.completedDayIDs.count } let habits = arc.habits ?? []
let possibleCheckIns = arc.habits.count * arc.durationDays let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = habits.count * arc.durationDays
let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0 let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
return HStack { return HStack {

View File

@ -24,8 +24,9 @@ struct ArcRenewalSheet: View {
private var arcSummary: String { private var arcSummary: String {
guard let arc = completedArc else { return "" } guard let arc = completedArc else { return "" }
let totalHabits = arc.habits.count let habits = arc.habits ?? []
let totalCheckIns = arc.habits.reduce(0) { $0 + $1.completedDayIDs.count } let totalHabits = habits.count
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = totalHabits * arc.durationDays let possibleCheckIns = totalHabits * arc.durationDays
let rate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0 let rate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
return String(localized: "\(rate)% completion over \(arc.durationDays) days") return String(localized: "\(rate)% completion over \(arc.durationDays) days")
@ -94,7 +95,7 @@ struct ArcRenewalSheet: View {
.font(.subheadline) .font(.subheadline)
if let arc = completedArc { if let arc = completedArc {
let habitCount = arc.habits.count let habitCount = (arc.habits ?? []).count
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)

View File

@ -1,6 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import Foundation
struct RootView: View { struct RootView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@ -85,9 +84,7 @@ struct RootView: View {
Task { Task {
// Let tab selection UI update before refreshing data. // Let tab selection UI update before refreshing data.
await Task.yield() await Task.yield()
let refreshStart = CFAbsoluteTimeGetCurrent()
store.refresh() store.refresh()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.store.refresh", from: refreshStart)
analyticsPrewarmTask?.cancel() analyticsPrewarmTask?.cancel()
if selectedTab != .insights { if selectedTab != .insights {
analyticsPrewarmTask = Task { @MainActor in analyticsPrewarmTask = Task { @MainActor in
@ -98,12 +95,8 @@ struct RootView: View {
} }
} }
if selectedTab == .settings { if selectedTab == .settings {
let settingsStart = CFAbsoluteTimeGetCurrent()
settingsStore.refresh() settingsStore.refresh()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.settings.refresh", from: settingsStart)
let reminderStart = CFAbsoluteTimeGetCurrent()
await store.reminderScheduler.refreshStatus() await store.reminderScheduler.refreshStatus()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.reminderStatus", from: reminderStart)
} }
} }
} }