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 cachedDatesWithActivity: Set<Date> = []
private var cachedPerfectDayIDs: Set<String> = [] private var cachedPerfectDayIDs: Set<String> = []
private var pendingReminderTask: Task<Void, Never>? private var pendingReminderTask: Task<Void, Never>?
private var insightCardsNeedRefresh = true
private var cachedInsightCards: [InsightCard] = []
/// Reminder scheduler for time-slot based notifications /// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler() let reminderScheduler = ReminderScheduler()
@ -401,7 +403,19 @@ final class RitualStore: RitualStoreProviding {
} }
func insightCards() -> [InsightCard] { 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 // Only count habits from active arcs for today's stats
let activeHabitsToday = habitsActive(on: Date()) let activeHabitsToday = habitsActive(on: Date())
let totalHabits = activeHabitsToday.count let totalHabits = activeHabitsToday.count
@ -481,7 +495,6 @@ final class RitualStore: RitualStoreProviding {
) )
] ]
} }
}
func createQuickRitual() { func createQuickRitual() {
let defaultDuration = 28 let defaultDuration = 28
@ -675,6 +688,7 @@ final class RitualStore: RitualStoreProviding {
private func invalidateAnalyticsCache() { private func invalidateAnalyticsCache() {
analyticsNeedsRefresh = true analyticsNeedsRefresh = true
insightCardsNeedRefresh = true
} }
private func computeDatesWithActivity() -> Set<Date> { private func computeDatesWithActivity() -> Set<Date> {

View File

@ -87,9 +87,16 @@ 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 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) { 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 { if let date = date {
let isToday = calendar.isDate(date, inSameDayAs: today) let isToday = calendar.isDate(date, inSameDayAs: today)
let isFuture = date > 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 // Show all days - future days show with 0 progress and are not tappable
HistoryDayCell( HistoryDayCell(
date: date, date: date,
progress: isFuture ? 0 : completionRate(date, selectedRitual), progress: progressValues[index] ?? 0,
isToday: isToday, isToday: isToday,
isFuture: isFuture, isFuture: isFuture,
onTap: { if !isFuture { onDayTapped(date) } } onTap: { if !isFuture { onDayTapped(date) } }

View File

@ -19,21 +19,27 @@ struct HistoryView: View {
@Bindable var store: RitualStore @Bindable var store: RitualStore
@State private var selectedRitual: Ritual? @State private var selectedRitual: Ritual?
@State private var selectedDateItem: IdentifiableDate? @State private var selectedDateItem: IdentifiableDate?
@State private var showingExpandedHistory = false @State private var monthsToShow = 2
@State private var refreshToken = UUID() @State private var refreshToken = UUID()
@State private var cachedProgressByDate: [Date: Double] = [:]
private let calendar = Calendar.current private let calendar = Calendar.current
private let baseMonthsToShow = 2
private let monthChunkSize = 6
/// Generate months based on expanded state /// Generate months based on expanded state
/// - Collapsed: Last month + current month (2 months) /// - Collapsed: Last month + current month (2 months)
/// - 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 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 { guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else {
return [currentMonth] return [currentMonth]
} }
@ -49,13 +55,15 @@ struct HistoryView: View {
return result return result
} }
}
/// Check if there's more history available beyond what's shown /// Check if there's more history available beyond what's shown
private var hasMoreHistory: Bool { private var hasMoreHistory: Bool {
guard let earliestActivity = store.earliestActivityDate() else { return false } guard let earliestActivity = store.earliestActivityDate() else { return false }
let today = Date() guard let oldestVisibleMonth = months.first else { return false }
guard let twoMonthsAgo = calendar.date(byAdding: .month, value: -1, to: today) else { return false } let oldestVisibleStart = calendar.startOfDay(for: oldestVisibleMonth)
return earliestActivity < twoMonthsAgo let earliestStart = calendar.startOfDay(for: earliestActivity)
return earliestStart < oldestVisibleStart
} }
var body: some View { var body: some View {
@ -73,7 +81,8 @@ struct HistoryView: View {
month: month, month: month,
selectedRitual: selectedRitual, selectedRitual: selectedRitual,
completionRate: { date, ritual in 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 onDayTapped: { date in
selectedDateItem = IdentifiableDate(date: date) selectedDateItem = IdentifiableDate(date: date)
@ -93,8 +102,20 @@ struct HistoryView: View {
if let selectedRitual { if let selectedRitual {
self.selectedRitual = newRituals.first { $0.id == selectedRitual.id } self.selectedRitual = newRituals.first { $0.id == selectedRitual.id }
} }
refreshProgressCache()
refreshToken = UUID() refreshToken = UUID()
} }
.onChange(of: selectedRitual) { _, _ in
refreshProgressCache()
refreshToken = UUID()
}
.onChange(of: monthsToShow) { _, _ in
refreshProgressCache()
refreshToken = UUID()
}
.onAppear {
refreshProgressCache()
}
.sheet(item: $selectedDateItem) { item in .sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet( HistoryDayDetailSheet(
date: item.date, date: item.date,
@ -116,13 +137,18 @@ struct HistoryView: View {
if hasMoreHistory || showingExpandedHistory { if hasMoreHistory || showingExpandedHistory {
Button { Button {
withAnimation(.easeInOut(duration: Design.Animation.standard)) { 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: { } label: {
HStack(spacing: Design.Spacing.xSmall) { 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) .font(.subheadline)
Image(systemName: showingExpandedHistory ? "chevron.up" : "chevron.down") Image(systemName: monthsToShow > baseMonthsToShow ? "chevron.up" : "chevron.down")
.font(.caption) .font(.caption)
} }
.foregroundStyle(AppAccent.primary) .foregroundStyle(AppAccent.primary)
@ -168,6 +194,43 @@ struct HistoryView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? .isSelected : []) .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 { #Preview {

View File

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

View File

@ -8,6 +8,7 @@ struct RootView: View {
@Bindable var categoryStore: CategoryStore @Bindable var categoryStore: CategoryStore
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: RootTab @State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>?
/// The available tabs in the app. /// The available tabs in the app.
enum RootTab: Hashable { enum RootTab: Hashable {
@ -87,6 +88,15 @@ struct RootView: View {
let refreshStart = CFAbsoluteTimeGetCurrent() let refreshStart = CFAbsoluteTimeGetCurrent()
store.refresh() store.refresh()
PerformanceLogger.logDuration("RootView.refreshCurrentTab.store.refresh", from: refreshStart) 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 { if selectedTab == .settings {
let settingsStart = CFAbsoluteTimeGetCurrent() let settingsStart = CFAbsoluteTimeGetCurrent()
settingsStore.refresh() settingsStore.refresh()