diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 6d16a57..770668e 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -21,6 +21,8 @@ final class RitualStore: RitualStoreProviding { private var cachedDatesWithActivity: Set = [] private var cachedPerfectDayIDs: Set = [] private var pendingReminderTask: Task? + private var insightCardsNeedRefresh = true + private var cachedInsightCards: [InsightCard] = [] /// Reminder scheduler for time-slot based notifications let reminderScheduler = ReminderScheduler() @@ -401,86 +403,97 @@ final class RitualStore: RitualStoreProviding { } func insightCards() -> [InsightCard] { - return PerformanceLogger.measure("RitualStore.insightCards") { - // Only count habits from active arcs for today's stats - let activeHabitsToday = habitsActive(on: Date()) - 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 = currentRituals.count - - // Build per-ritual progress breakdown - let habitsBreakdown = currentRituals.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))%") - } + refreshInsightCardsIfNeeded() + return cachedInsightCards + } - return [ - InsightCard( - 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: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) } - ), - InsightCard( - 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 - ), - InsightCard( - 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 - ), - InsightCard( - 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 - ), - InsightCard( - 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() - ) - ] + func refreshInsightCardsIfNeeded() { + guard insightCardsNeedRefresh else { return } + cachedInsightCards = PerformanceLogger.measure("RitualStore.insightCards") { + computeInsightCards() } + insightCardsNeedRefresh = false + } + + private func computeInsightCards() -> [InsightCard] { + // Only count habits from active arcs for today's stats + let activeHabitsToday = habitsActive(on: Date()) + 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 = currentRituals.count + + // Build per-ritual progress breakdown + let habitsBreakdown = currentRituals.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))%") + } + + return [ + InsightCard( + 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: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) } + ), + InsightCard( + 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 + ), + InsightCard( + 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 + ), + InsightCard( + 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 + ), + InsightCard( + 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() + ) + ] } func createQuickRitual() { @@ -675,6 +688,7 @@ final class RitualStore: RitualStoreProviding { private func invalidateAnalyticsCache() { analyticsNeedsRefresh = true + insightCardsNeedRefresh = true } private func computeDatesWithActivity() -> Set { diff --git a/Andromida/App/Views/History/HistoryMonthView.swift b/Andromida/App/Views/History/HistoryMonthView.swift index b888981..9879953 100644 --- a/Andromida/App/Views/History/HistoryMonthView.swift +++ b/Andromida/App/Views/History/HistoryMonthView.swift @@ -87,9 +87,16 @@ 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 + guard let date else { return nil } + return date > today ? 0 : completionRate(date, selectedRitual) + } + } return LazyVGrid(columns: columns, spacing: Design.Spacing.xSmall) { - ForEach(Array(daysInMonth.enumerated()), id: \.offset) { index, date in + ForEach(Array(dates.enumerated()), id: \.offset) { index, date in if let date = date { let isToday = calendar.isDate(date, inSameDayAs: today) let isFuture = date > today @@ -97,7 +104,7 @@ struct HistoryMonthView: View { // Show all days - future days show with 0 progress and are not tappable HistoryDayCell( date: date, - progress: isFuture ? 0 : completionRate(date, selectedRitual), + progress: progressValues[index] ?? 0, isToday: isToday, isFuture: isFuture, onTap: { if !isFuture { onDayTapped(date) } } diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index bd89703..3223c6b 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -19,43 +19,51 @@ struct HistoryView: View { @Bindable var store: RitualStore @State private var selectedRitual: Ritual? @State private var selectedDateItem: IdentifiableDate? - @State private var showingExpandedHistory = false + @State private var monthsToShow = 2 @State private var refreshToken = UUID() + @State private var cachedProgressByDate: [Date: Double] = [:] private let calendar = Calendar.current + private let baseMonthsToShow = 2 + private let monthChunkSize = 6 /// Generate months based on expanded state /// - Collapsed: Last month + current month (2 months) /// - Expanded: Up to 12 months of history /// Months are ordered oldest first, newest last (chronological order) private var months: [Date] { - let today = Date() - let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today - - // Determine how far back to go - let monthsBack = showingExpandedHistory ? 11 : 1 // 12 months or 2 months total - guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else { - return [currentMonth] + PerformanceLogger.measure("HistoryView.months") { + let today = Date() + let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today + + // Determine how far back to go + let totalAvailableMonths = totalMonthsAvailable(from: currentMonth) + let effectiveMonthsToShow = min(monthsToShow, totalAvailableMonths) + let monthsBack = max(0, effectiveMonthsToShow - 1) + guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else { + 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 private var hasMoreHistory: Bool { guard let earliestActivity = store.earliestActivityDate() else { return false } - let today = Date() - guard let twoMonthsAgo = calendar.date(byAdding: .month, value: -1, to: today) else { return false } - return earliestActivity < twoMonthsAgo + guard let oldestVisibleMonth = months.first else { return false } + let oldestVisibleStart = calendar.startOfDay(for: oldestVisibleMonth) + let earliestStart = calendar.startOfDay(for: earliestActivity) + return earliestStart < oldestVisibleStart } var body: some View { @@ -73,7 +81,8 @@ struct HistoryView: View { month: month, selectedRitual: selectedRitual, completionRate: { date, ritual in - store.completionRate(for: date, ritual: ritual) + let day = calendar.startOfDay(for: date) + return cachedProgressByDate[day] ?? store.completionRate(for: day, ritual: ritual) }, onDayTapped: { date in selectedDateItem = IdentifiableDate(date: date) @@ -93,8 +102,20 @@ struct HistoryView: View { if let selectedRitual { self.selectedRitual = newRituals.first { $0.id == selectedRitual.id } } + refreshProgressCache() refreshToken = UUID() } + .onChange(of: selectedRitual) { _, _ in + refreshProgressCache() + refreshToken = UUID() + } + .onChange(of: monthsToShow) { _, _ in + refreshProgressCache() + refreshToken = UUID() + } + .onAppear { + refreshProgressCache() + } .sheet(item: $selectedDateItem) { item in HistoryDayDetailSheet( date: item.date, @@ -116,13 +137,18 @@ struct HistoryView: View { if hasMoreHistory || showingExpandedHistory { Button { withAnimation(.easeInOut(duration: Design.Animation.standard)) { - showingExpandedHistory.toggle() + if monthsToShow > baseMonthsToShow { + monthsToShow = baseMonthsToShow + } else { + let totalAvailableMonths = totalMonthsAvailable(from: Date()) + monthsToShow = min(monthsToShow + monthChunkSize, totalAvailableMonths) + } } } label: { HStack(spacing: Design.Spacing.xSmall) { - Text(showingExpandedHistory ? String(localized: "Show less") : String(localized: "Show more")) + Text(monthsToShow > baseMonthsToShow ? String(localized: "Show less") : String(localized: "Show more")) .font(.subheadline) - Image(systemName: showingExpandedHistory ? "chevron.up" : "chevron.down") + Image(systemName: monthsToShow > baseMonthsToShow ? "chevron.up" : "chevron.down") .font(.caption) } .foregroundStyle(AppAccent.primary) @@ -168,6 +194,43 @@ struct HistoryView: View { .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } + + private func refreshProgressCache() { + Task { @MainActor in + 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()) + + for month in snapshotMonths { + guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)), + let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { + 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 + } + cachedProgressByDate = cache + } + } + + private func totalMonthsAvailable(from currentMonth: Date) -> Int { + guard let earliestActivity = store.earliestActivityDate() else { return baseMonthsToShow } + let earliestMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: earliestActivity)) ?? currentMonth + let comps = calendar.dateComponents([.month], from: earliestMonth, to: currentMonth) + let months = (comps.month ?? 0) + 1 + return max(baseMonthsToShow, months) + } } #Preview { diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index b286599..f05afa7 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -32,10 +32,15 @@ struct InsightsView: View { endPoint: .bottomTrailing )) .onAppear { - store.refreshAnalyticsIfNeeded() + Task { + await Task.yield() + store.refreshAnalyticsIfNeeded() + store.refreshInsightCardsIfNeeded() + } } .onChange(of: store.rituals) { _, _ in store.refreshAnalyticsIfNeeded() + store.refreshInsightCardsIfNeeded() refreshToken = UUID() } } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index b1fe431..742bb23 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -8,6 +8,7 @@ struct RootView: View { @Bindable var categoryStore: CategoryStore @Environment(\.scenePhase) private var scenePhase @State private var selectedTab: RootTab + @State private var analyticsPrewarmTask: Task? /// The available tabs in the app. enum RootTab: Hashable { @@ -87,6 +88,15 @@ struct RootView: View { let refreshStart = CFAbsoluteTimeGetCurrent() store.refresh() PerformanceLogger.logDuration("RootView.refreshCurrentTab.store.refresh", from: refreshStart) + analyticsPrewarmTask?.cancel() + if selectedTab != .insights { + analyticsPrewarmTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + store.refreshAnalyticsIfNeeded() + store.refreshInsightCardsIfNeeded() + } + } if selectedTab == .settings { let settingsStart = CFAbsoluteTimeGetCurrent() settingsStore.refresh()