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">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<array>
<string>iCloud.com.mbrucedogs.Andromida</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>

View File

@ -16,7 +16,11 @@ struct AndromidaApp: App {
init() {
// Include all models in schema - Ritual, RitualArc, and ArcHabit
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
do {
container = try ModelContainer(for: schema, configurations: [configuration])

View File

@ -6,11 +6,11 @@ import SwiftData
/// while preserving historical data.
@Model
final class ArcHabit {
var id: UUID
var title: String
var symbolName: String
var goal: String
var completedDayIDs: [String]
var id: UUID = UUID()
var title: String = ""
var symbolName: String = ""
var goal: String = ""
var completedDayIDs: [String] = []
@Relationship(inverse: \RitualArc.habits)
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.
@Model
final class Ritual {
var id: UUID
var title: String
var theme: String
var notes: String
var id: UUID = UUID()
var title: String = ""
var theme: String = ""
var notes: String = ""
// Default duration for new arcs
var defaultDurationDays: Int
var defaultDurationDays: Int = 28
// Scheduling
var timeOfDay: TimeOfDay
var timeOfDay: TimeOfDay = .anytime
// Organization
var iconName: String
var category: String
var iconName: String = "sparkles"
var category: String = ""
// Arcs - each arc represents a time-bound period with its own habits
@Relationship(deleteRule: .cascade)
var arcs: [RitualArc]
var arcs: [RitualArc]? = []
init(
id: UUID = UUID(),
@ -118,7 +118,7 @@ final class Ritual {
/// The currently active arc, if any.
var currentArc: RitualArc? {
arcs.first { $0.isActive }
arcs?.first { $0.isActive }
}
/// Whether this ritual has an active arc in progress.
@ -128,7 +128,7 @@ final class Ritual {
/// All arcs sorted by start date (newest first).
var sortedArcs: [RitualArc] {
arcs.sorted { $0.startDate > $1.startDate }
(arcs ?? []).sorted { $0.startDate > $1.startDate }
}
/// The most recent arc (active or completed).
@ -138,12 +138,12 @@ final class Ritual {
/// Total number of completed arcs.
var completedArcCount: Int {
arcs.filter { !$0.isActive }.count
(arcs ?? []).filter { !$0.isActive }.count
}
/// The end date of the most recently completed arc, if any.
var lastCompletedDate: Date? {
arcs.filter { !$0.isActive }
(arcs ?? []).filter { !$0.isActive }
.sorted { $0.endDate > $1.endDate }
.first?.endDate
}
@ -174,12 +174,12 @@ final class Ritual {
/// Returns the arc that was active on a specific date, if any.
func arc(for date: Date) -> RitualArc? {
arcs.first { $0.contains(date: date) }
(arcs ?? []).first { $0.contains(date: date) }
}
/// Returns all arcs that overlap with a date range.
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.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.
@Model
final class RitualArc {
var id: UUID
var startDate: Date
var endDate: Date
var arcNumber: Int
var isActive: Bool
var id: UUID = UUID()
var startDate: Date = Date()
var endDate: Date = Date()
var arcNumber: Int = 1
var isActive: Bool = true
@Relationship(deleteRule: .cascade)
var habits: [ArcHabit]
var habits: [ArcHabit]? = []
@Relationship(inverse: \Ritual.arcs)
var ritual: Ritual?
@ -88,7 +88,7 @@ final class RitualArc {
let newStartDate = calendar.date(byAdding: .day, value: 1, to: endDate) ?? Date()
let newDuration = durationDays ?? self.durationDays
let copiedHabits = habits.map { $0.copyForNewArc() }
let copiedHabits = (habits ?? []).map { $0.copyForNewArc() }
return RitualArc(
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,11 +72,9 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time.
func refresh() {
PerformanceLogger.measure("RitualStore.refresh") {
reloadRituals()
checkForCompletedArcs()
}
}
func ritualProgress(for ritual: Ritual) -> Double {
let habits = ritual.habits
@ -174,12 +172,12 @@ final class RitualStore: RitualStoreProviding {
/// 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) }
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 }
arcsActive(on: date).flatMap { $0.habits ?? [] }
}
/// Checks if a ritual's current arc has completed (past end date).
@ -217,7 +215,7 @@ final class RitualStore: RitualStoreProviding {
let newHabits: [ArcHabit]
if copyHabits, let previousArc = ritual.latestArc {
newHabits = previousArc.habits.map { $0.copyForNewArc() }
newHabits = (previousArc.habits ?? []).map { $0.copyForNewArc() }
} else {
newHabits = []
}
@ -230,7 +228,9 @@ final class RitualStore: RitualStoreProviding {
habits: newHabits
)
ritual.arcs.append(newArc)
var arcs = ritual.arcs ?? []
arcs.append(newArc)
ritual.arcs = arcs
saveContext()
}
@ -360,7 +360,10 @@ final class RitualStore: RitualStoreProviding {
var breakdown: [BreakdownItem] = []
// 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(
label: String(localized: "Total check-ins"),
value: "\(totalCheckIns)"
@ -392,7 +395,7 @@ final class RitualStore: RitualStoreProviding {
// Per-ritual breakdown
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(
label: ritual.title,
value: String(localized: "\(ritualDays) days")
@ -409,9 +412,7 @@ final class RitualStore: RitualStoreProviding {
func refreshInsightCardsIfNeeded() {
guard insightCardsNeedRefresh else { return }
cachedInsightCards = PerformanceLogger.measure("RitualStore.insightCards") {
computeInsightCards()
}
cachedInsightCards = computeInsightCards()
insightCardsNeedRefresh = false
}
@ -630,14 +631,18 @@ final class RitualStore: RitualStoreProviding {
func addHabit(to ritual: Ritual, title: String, symbolName: String) {
guard let arc = ritual.currentArc else { return }
let habit = ArcHabit(title: title, symbolName: symbolName)
arc.habits.append(habit)
var habits = arc.habits ?? []
habits.append(habit)
arc.habits = habits
saveContext()
}
/// Removes a habit from the current arc of a ritual
func removeHabit(_ habit: ArcHabit, from ritual: Ritual) {
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)
saveContext()
}
@ -649,7 +654,6 @@ final class RitualStore: RitualStoreProviding {
}
private func reloadRituals() {
PerformanceLogger.measure("RitualStore.reloadRituals") {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
updateDerivedData()
@ -659,7 +663,6 @@ final class RitualStore: RitualStoreProviding {
lastErrorMessage = error.localizedDescription
}
}
}
private func saveContext() {
do {
@ -692,12 +695,11 @@ final class RitualStore: RitualStoreProviding {
}
private func computeDatesWithActivity() -> Set<Date> {
PerformanceLogger.measure("RitualStore.computeDatesWithActivity") {
var dates: Set<Date> = []
for ritual in rituals {
for arc in ritual.arcs {
for habit in arc.habits {
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))
@ -709,7 +711,6 @@ final class RitualStore: RitualStoreProviding {
return dates
}
}
private func computePerfectDays(from activeDates: Set<Date>) -> Set<String> {
guard !activeDates.isEmpty else { return [] }
@ -756,7 +757,7 @@ final class RitualStore: RitualStoreProviding {
if let ritual = ritual {
// Get habits from the arc that was active on this date
if let arc = ritual.arc(for: date) {
habits = arc.habits
habits = arc.habits ?? []
} else {
return 0
}
@ -792,7 +793,7 @@ final class RitualStore: RitualStoreProviding {
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 {
for habit in arc.habits ?? [] {
completions.append(HabitCompletion(
habit: habit,
ritualTitle: ritual.title,
@ -804,7 +805,7 @@ final class RitualStore: RitualStoreProviding {
// 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 {
for habit in arc.habits ?? [] {
completions.append(HabitCompletion(
habit: habit,
ritualTitle: r.title,
@ -885,14 +886,16 @@ final class RitualStore: RitualStoreProviding {
/// Returns the current streak for a specific ritual's current arc.
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 checkDate = calendar.startOfDay(for: Date())
while arc.contains(date: 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 {
streak += 1
@ -993,7 +996,7 @@ final class RitualStore: RitualStoreProviding {
// 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 {
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
@ -1001,7 +1004,7 @@ final class RitualStore: RitualStoreProviding {
}
// If no arcs exist, create one
if ritual.arcs.isEmpty {
if (ritual.arcs ?? []).isEmpty {
let demoHabits = [
ArcHabit(title: "Demo Habit 1", symbolName: "star.fill"),
ArcHabit(title: "Demo Habit 2", symbolName: "heart.fill")
@ -1013,7 +1016,9 @@ final class RitualStore: RitualStoreProviding {
isActive: true,
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)
for ritual in rituals {
for arc in ritual.arcs {
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 {
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
@ -1049,8 +1054,8 @@ final class RitualStore: RitualStoreProviding {
/// Clears all completion data (for testing).
func clearAllCompletions() {
for ritual in rituals {
for arc in ritual.arcs {
for habit in arc.habits {
for arc in ritual.arcs ?? [] {
for habit in arc.habits ?? [] {
habit.completedDayIDs.removeAll()
}
}

View File

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

View File

@ -32,7 +32,6 @@ struct HistoryView: View {
/// - Expanded: Up to 12 months of history
/// Months are ordered oldest first, newest last (chronological order)
private var months: [Date] {
PerformanceLogger.measure("HistoryView.months") {
let today = Date()
let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today
@ -55,7 +54,6 @@ struct HistoryView: View {
return result
}
}
/// Check if there's more history available beyond what's shown
private var hasMoreHistory: Bool {
@ -200,7 +198,6 @@ struct HistoryView: View {
await Task.yield()
let snapshotMonths = months
let selected = selectedRitual
let cache = PerformanceLogger.measure("HistoryView.progressCache") {
var result: [Date: Double] = [:]
let today = calendar.startOfDay(for: Date())
@ -217,9 +214,7 @@ struct HistoryView: View {
result[normalizedDate] = store.completionRate(for: normalizedDate, ritual: selected)
}
}
return result
}
let cache = result
cachedProgressByDate = cache
}
}

View File

@ -36,11 +36,11 @@ struct RitualDetailView: View {
}
private var hasMultipleArcs: Bool {
ritual.arcs.count > 1
(ritual.arcs ?? []).count > 1
}
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 {
@ -353,7 +353,7 @@ struct RitualDetailView: View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
SectionHeaderView(
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 {
@ -374,8 +374,9 @@ struct RitualDetailView: View {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
let totalCheckIns = arc.habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = arc.habits.count * arc.durationDays
let habits = arc.habits ?? []
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
return HStack {

View File

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

View File

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