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,86 +403,97 @@ final class RitualStore: RitualStoreProviding {
} }
func insightCards() -> [InsightCard] { func insightCards() -> [InsightCard] {
return PerformanceLogger.measure("RitualStore.insightCards") { refreshInsightCardsIfNeeded()
// Only count habits from active arcs for today's stats return cachedInsightCards
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 [ func refreshInsightCardsIfNeeded() {
InsightCard( guard insightCardsNeedRefresh else { return }
title: String(localized: "Active"), cachedInsightCards = PerformanceLogger.measure("RitualStore.insightCards") {
value: "\(activeRitualCount)", computeInsightCards()
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()
)
]
} }
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() { func createQuickRitual() {
@ -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,43 +19,51 @@ 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] {
let today = Date() PerformanceLogger.measure("HistoryView.months") {
let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today 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 // Determine how far back to go
guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else { let totalAvailableMonths = totalMonthsAvailable(from: currentMonth)
return [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 /// 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 {
store.refreshAnalyticsIfNeeded() Task {
await Task.yield()
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()