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

This commit is contained in:
Matt Bruce 2026-01-26 17:05:31 -06:00
parent 9ade3b00ea
commit 2eb2abfba8
5 changed files with 206 additions and 107 deletions

View File

@ -21,6 +21,8 @@ final class RitualStore: RitualStoreProviding {
private var cachedDatesWithActivity: Set<Date> = []
private var cachedPerfectDayIDs: Set<String> = []
private var pendingReminderTask: Task<Void, Never>?
private var insightCardsNeedRefresh = true
private var cachedInsightCards: [InsightCard] = []
/// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler()
@ -401,7 +403,19 @@ final class RitualStore: RitualStoreProviding {
}
func insightCards() -> [InsightCard] {
return PerformanceLogger.measure("RitualStore.insightCards") {
refreshInsightCardsIfNeeded()
return cachedInsightCards
}
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
@ -481,7 +495,6 @@ final class RitualStore: RitualStoreProviding {
)
]
}
}
func createQuickRitual() {
let defaultDuration = 28
@ -675,6 +688,7 @@ final class RitualStore: RitualStoreProviding {
private func invalidateAnalyticsCache() {
analyticsNeedsRefresh = true
insightCardsNeedRefresh = true
}
private func computeDatesWithActivity() -> Set<Date> {

View File

@ -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) } }

View File

@ -19,21 +19,27 @@ 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] {
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 monthsBack = showingExpandedHistory ? 11 : 1 // 12 months or 2 months total
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]
}
@ -49,13 +55,15 @@ struct HistoryView: View {
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 {

View File

@ -32,10 +32,15 @@ struct InsightsView: View {
endPoint: .bottomTrailing
))
.onAppear {
Task {
await Task.yield()
store.refreshAnalyticsIfNeeded()
store.refreshInsightCardsIfNeeded()
}
}
.onChange(of: store.rituals) { _, _ in
store.refreshAnalyticsIfNeeded()
store.refreshInsightCardsIfNeeded()
refreshToken = UUID()
}
}

View File

@ -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<Void, Never>?
/// 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()