initial version
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class DashboardViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published var portfolioSummary: PortfolioSummary = .empty
|
||||
@Published var monthlySummary: MonthlySummary = .empty
|
||||
@Published var categoryMetrics: [CategoryMetrics] = []
|
||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||
@Published var recentSnapshots: [Snapshot] = []
|
||||
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
|
||||
@Published var latestPortfolioChange: PortfolioChange = .empty
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let calculationService: CalculationService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private let maxHistoryMonths = 60
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
private var cachedFilteredSources: [InvestmentSource]?
|
||||
private var cachedSourcesHash: Int = 0
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
categoryRepository: CategoryRepository? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
calculationService: CalculationService? = nil
|
||||
) {
|
||||
self.categoryRepository = categoryRepository ?? CategoryRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
|
||||
setupObservers()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupObservers() {
|
||||
// Performance: Combine multiple publishers to reduce redundant refresh calls
|
||||
// Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes
|
||||
Publishers.Merge(
|
||||
categoryRepository.$categories.map { _ in () },
|
||||
sourceRepository.$sources.map { _ in () }
|
||||
)
|
||||
.dropFirst(2) // Skip initial values from both publishers
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.invalidateCache()
|
||||
self?.refreshData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe Core Data changes with coalescing
|
||||
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
||||
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.compactMap { [weak self] notification -> Void? in
|
||||
guard let self, self.isRelevantChange(notification) else { return nil }
|
||||
return ()
|
||||
}
|
||||
.sink { [weak self] _ in
|
||||
self?.invalidateCache()
|
||||
self?.refreshData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
guard let info = notification.userInfo else { return false }
|
||||
let keys: [String] = [
|
||||
NSInsertedObjectsKey,
|
||||
NSUpdatedObjectsKey,
|
||||
NSDeletedObjectsKey,
|
||||
NSRefreshedObjectsKey
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let objects = info[key] as? Set<NSManagedObject> {
|
||||
if objects.contains(where: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
queueRefresh(updateLoadingFlag: true)
|
||||
}
|
||||
|
||||
func refreshData() {
|
||||
queueRefresh(updateLoadingFlag: false)
|
||||
}
|
||||
|
||||
private func queueRefresh(updateLoadingFlag: Bool) {
|
||||
refreshQueued = true
|
||||
if updateLoadingFlag {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
guard !isRefreshing else { return }
|
||||
|
||||
isRefreshing = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while self.refreshQueued {
|
||||
self.refreshQueued = false
|
||||
await self.refreshAllData()
|
||||
}
|
||||
self.isRefreshing = false
|
||||
if updateLoadingFlag {
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAllData() async {
|
||||
let categories = categoryRepository.categories
|
||||
let sources = filteredSources()
|
||||
let allSnapshots = filteredSnapshots(for: sources)
|
||||
|
||||
// Calculate portfolio summary
|
||||
portfolioSummary = calculationService.calculatePortfolioSummary(
|
||||
from: sources,
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
|
||||
monthlySummary = calculationService.calculateMonthlySummary(
|
||||
sources: sources,
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
|
||||
// Calculate category metrics
|
||||
categoryMetrics = calculationService.calculateCategoryMetrics(
|
||||
for: categories,
|
||||
sources: sources,
|
||||
totalPortfolioValue: portfolioSummary.totalValue
|
||||
).sorted { $0.totalValue > $1.totalValue }
|
||||
|
||||
// Get recent snapshots
|
||||
recentSnapshots = Array(allSnapshots.prefix(10))
|
||||
|
||||
// Get sources needing update
|
||||
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||
|
||||
// Calculate evolution data for chart
|
||||
updateEvolutionData(from: allSnapshots, categories: categories)
|
||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
// Performance: Cache filtered sources to avoid repeated filtering
|
||||
let currentHash = sourceRepository.sources.count
|
||||
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
|
||||
|
||||
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
|
||||
lastAccountId = selectedAccount?.id
|
||||
lastShowAllAccounts = showAllAccounts
|
||||
cachedSourcesHash = currentHash
|
||||
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
cachedFilteredSources = sourceRepository.sources
|
||||
} else {
|
||||
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||
}
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
|
||||
/// Invalidates cached data when underlying data changes
|
||||
private func invalidateCache() {
|
||||
cachedFilteredSources = nil
|
||||
cachedSourcesHash = 0
|
||||
}
|
||||
|
||||
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
return snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
}
|
||||
|
||||
private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
|
||||
let summary = calculateEvolutionSummary(from: snapshots)
|
||||
let categoriesWithData = Set(summary.categoryTotals.keys)
|
||||
let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||
|
||||
evolutionData = summary.evolutionData
|
||||
categoryEvolutionData = summary.categorySeries.flatMap { entry in
|
||||
entry.valuesByCategory.compactMap { categoryId, value in
|
||||
guard categoriesWithData.contains(categoryId),
|
||||
let category = categoryLookup[categoryId] else {
|
||||
return nil
|
||||
}
|
||||
return CategoryEvolutionPoint(
|
||||
date: entry.date,
|
||||
categoryName: category.name,
|
||||
colorHex: category.colorHex,
|
||||
value: value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvolutionSummary {
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])]
|
||||
let categoryTotals: [UUID: Decimal]
|
||||
}
|
||||
|
||||
private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary {
|
||||
guard !snapshots.isEmpty else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
// Performance: Pre-allocate capacity and use more efficient data structures
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
// Use dictionary to deduplicate dates more efficiently
|
||||
var uniqueDateSet = Set<Date>()
|
||||
uniqueDateSet.reserveCapacity(sortedSnapshots.count)
|
||||
for snapshot in sortedSnapshots {
|
||||
uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date))
|
||||
}
|
||||
let uniqueDates = uniqueDateSet.sorted()
|
||||
|
||||
guard !uniqueDates.isEmpty else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
// Pre-allocate dictionaries with estimated capacity
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
|
||||
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
let categoryId = snapshot.source?.category?.id
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
|
||||
)
|
||||
if let categoryId {
|
||||
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-allocate result arrays
|
||||
var indices: [UUID: Int] = [:]
|
||||
indices.reserveCapacity(snapshotsBySource.count)
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(uniqueDates.count)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(uniqueDates.count)
|
||||
|
||||
// Track last known value per source for carry-forward optimization
|
||||
var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:]
|
||||
lastValues.reserveCapacity(snapshotsBySource.count)
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
let snap = sourceSnapshots[currentIndex]
|
||||
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
// Use last known value (carry-forward)
|
||||
if let lastValue = lastValues[sourceId] {
|
||||
total += lastValue.value
|
||||
if let categoryId = lastValue.categoryId {
|
||||
valuesByCategory[categoryId, default: 0] += lastValue.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
}
|
||||
|
||||
return EvolutionSummary(
|
||||
evolutionData: evolution,
|
||||
categorySeries: series,
|
||||
categoryTotals: categoryTotals
|
||||
)
|
||||
}
|
||||
|
||||
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
||||
guard data.count >= 2 else {
|
||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||
}
|
||||
let last = data[data.count - 1]
|
||||
let previous = data[data.count - 2]
|
||||
let absolute = last.value - previous.value
|
||||
let percentage = previous.value > 0
|
||||
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
|
||||
: 0
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||
}
|
||||
|
||||
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||
let target = goal.targetDecimal
|
||||
let lastValue = evolutionData.last?.value ?? currentValue
|
||||
|
||||
if currentValue >= target {
|
||||
return "Goal reached. Keep it steady."
|
||||
}
|
||||
|
||||
guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else {
|
||||
return "Keep going. Consistency pays off."
|
||||
}
|
||||
|
||||
if months == 0 {
|
||||
return "Almost there. One more check-in."
|
||||
}
|
||||
|
||||
let baseDate = evolutionData.last?.date ?? Date()
|
||||
let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate)
|
||||
if months >= 72 {
|
||||
return "Long journey, strong discipline. You're building momentum."
|
||||
}
|
||||
|
||||
if let estimatedDate = estimatedDate {
|
||||
return "Estimated: \(estimatedDate.monthYearString)"
|
||||
}
|
||||
|
||||
return "Estimated: \(months) months"
|
||||
}
|
||||
|
||||
private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? {
|
||||
guard evolutionData.count >= 3 else { return nil }
|
||||
let recent = Array(evolutionData.suffix(6))
|
||||
guard let first = recent.first, let last = recent.last else { return nil }
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else { return nil }
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else { return nil }
|
||||
let remaining = target - lastValue
|
||||
guard remaining > 0 else { return 0 }
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
return Int(ceil(months))
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var hasData: Bool {
|
||||
!filteredSources().isEmpty
|
||||
}
|
||||
|
||||
var totalSourceCount: Int {
|
||||
sourceRepository.sourceCount
|
||||
}
|
||||
|
||||
var totalCategoryCount: Int {
|
||||
categoryRepository.categories.count
|
||||
}
|
||||
|
||||
var pendingUpdatesCount: Int {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
Array(categoryMetrics.prefix(5))
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
var formattedTotalValue: String {
|
||||
portfolioSummary.formattedTotalValue
|
||||
}
|
||||
|
||||
var formattedDayChange: String {
|
||||
portfolioSummary.formattedDayChange
|
||||
}
|
||||
|
||||
var formattedMonthChange: String {
|
||||
portfolioSummary.formattedMonthChange
|
||||
}
|
||||
|
||||
var formattedYearChange: String {
|
||||
portfolioSummary.formattedYearChange
|
||||
}
|
||||
|
||||
var isDayChangePositive: Bool {
|
||||
portfolioSummary.dayChange >= 0
|
||||
}
|
||||
|
||||
var isMonthChangePositive: Bool {
|
||||
portfolioSummary.monthChange >= 0
|
||||
}
|
||||
|
||||
var isYearChangePositive: Bool {
|
||||
portfolioSummary.yearChange >= 0
|
||||
}
|
||||
|
||||
var formattedLastUpdate: String {
|
||||
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user