Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
9ade3b00ea
commit
2eb2abfba8
@ -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> {
|
||||||
|
|||||||
@ -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) } }
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user