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,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<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,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 {

View File

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

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()