Primera build enviada
This commit is contained in:
@@ -17,20 +17,27 @@ class DashboardViewModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var showingPaywall = false
|
||||
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
|
||||
// MARK: - Portfolio Forecast
|
||||
|
||||
@Published var portfolioForecast: PortfolioForecast?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let calculationService: CalculationService
|
||||
private let predictionEngine: PredictionEngine
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private let maxHistoryMonths = 60
|
||||
|
||||
// MARK: - Performance: Caching
|
||||
@@ -51,6 +58,7 @@ class DashboardViewModel: ObservableObject {
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
self.predictionEngine = .shared
|
||||
|
||||
setupObservers()
|
||||
loadData()
|
||||
@@ -128,9 +136,13 @@ class DashboardViewModel: ObservableObject {
|
||||
guard !isRefreshing else { return }
|
||||
|
||||
isRefreshing = true
|
||||
Task { [weak self] in
|
||||
|
||||
// Cancel any previous refresh task to avoid stale updates
|
||||
refreshTask?.cancel()
|
||||
|
||||
refreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while self.refreshQueued {
|
||||
while self.refreshQueued && !Task.isCancelled {
|
||||
self.refreshQueued = false
|
||||
await self.refreshAllData()
|
||||
}
|
||||
@@ -141,6 +153,12 @@ class DashboardViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel any ongoing background tasks - call when view disappears
|
||||
func cancelPendingTasks() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
private func refreshAllData() async {
|
||||
let categories = categoryRepository.categories
|
||||
let sources = filteredSources()
|
||||
@@ -171,31 +189,40 @@ class DashboardViewModel: ObservableObject {
|
||||
let accountFilter = showAllAccounts ? nil : selectedAccount
|
||||
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
|
||||
|
||||
// Calculate evolution data for chart
|
||||
updateEvolutionData(from: allSnapshots, categories: categories)
|
||||
// Calculate evolution data for chart (only completed monthly check-ins)
|
||||
let completedSnapshots = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
updateEvolutionData(from: completedSnapshots, categories: categories)
|
||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||
|
||||
// Calculate portfolio forecast
|
||||
updatePortfolioForecast()
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
let safeSelectedAccountId = selectedAccount?.safeId
|
||||
|
||||
// Performance: Cache filtered sources to avoid repeated filtering
|
||||
let currentHash = sourceRepository.sources.count
|
||||
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
|
||||
let accountChanged = lastAccountId != safeSelectedAccountId || lastShowAllAccounts != showAllAccounts
|
||||
|
||||
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
|
||||
lastAccountId = selectedAccount?.id
|
||||
lastAccountId = safeSelectedAccountId
|
||||
lastShowAllAccounts = showAllAccounts
|
||||
cachedSourcesHash = currentHash
|
||||
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
if showAllAccounts || safeSelectedAccountId == nil {
|
||||
cachedFilteredSources = sourceRepository.sources
|
||||
} else {
|
||||
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == safeSelectedAccountId }
|
||||
}
|
||||
return cachedFilteredSources!
|
||||
}
|
||||
@@ -247,80 +274,51 @@ class DashboardViewModel: ObservableObject {
|
||||
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: [:])
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
}
|
||||
|
||||
// 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 evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(groupedByMonth.count)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(groupedByMonth.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
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
for snapshot in latestBySource.values {
|
||||
let value = snapshot.decimalValue
|
||||
total += value
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
valuesByCategory[categoryId, default: 0] += value
|
||||
categoryTotals[categoryId, default: 0] += value
|
||||
}
|
||||
}
|
||||
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
}
|
||||
|
||||
evolution.sort { $0.date < $1.date }
|
||||
series.sort { $0.date < $1.date }
|
||||
|
||||
return EvolutionSummary(
|
||||
evolutionData: evolution,
|
||||
categorySeries: series,
|
||||
@@ -328,6 +326,60 @@ class DashboardViewModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func completedMonthKeys(
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot],
|
||||
after cutoff: Date
|
||||
) -> Set<DateComponents> {
|
||||
let sourceIds = Set(sources.compactMap { $0.id })
|
||||
guard !sourceIds.isEmpty else { return [] }
|
||||
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
}
|
||||
|
||||
var completed: Set<DateComponents> = []
|
||||
completed.reserveCapacity(groupedByMonth.count)
|
||||
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
guard let monthDate = Calendar.current.date(from: key) else { continue }
|
||||
guard monthDate > cutoff else { continue }
|
||||
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
|
||||
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
|
||||
if sourceIds.isSubset(of: monthSourceIds) {
|
||||
completed.insert(key)
|
||||
}
|
||||
}
|
||||
|
||||
return completed
|
||||
}
|
||||
|
||||
private func filterSnapshotsForCharts(
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
sources: sources,
|
||||
snapshots: snapshots,
|
||||
after: lastCompleted
|
||||
)
|
||||
|
||||
return snapshots.filter { snapshot in
|
||||
let monthDate = snapshot.date.startOfMonth
|
||||
if monthDate <= lastCompleted {
|
||||
return true
|
||||
}
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
let key = DateComponents(year: components.year, month: components.month)
|
||||
return completedMonthsAfter.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -341,6 +393,95 @@ class DashboardViewModel: ObservableObject {
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||
}
|
||||
|
||||
private func updatePortfolioForecast() {
|
||||
// Need at least 3 data points for meaningful forecast
|
||||
guard evolutionData.count >= 3 else {
|
||||
portfolioForecast = nil
|
||||
return
|
||||
}
|
||||
|
||||
let currentValue = evolutionData.last?.value ?? 0
|
||||
|
||||
// Use the evolution data to predict 12 months ahead
|
||||
// Create "virtual snapshots" from evolution data for the prediction engine
|
||||
let virtualSnapshots = evolutionData.map { dataPoint -> VirtualSnapshot in
|
||||
VirtualSnapshot(date: dataPoint.date, value: dataPoint.value)
|
||||
}
|
||||
|
||||
// Calculate using Holt-Winters or linear trend based on data volatility
|
||||
let values = virtualSnapshots.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
||||
guard values.count >= 3 else {
|
||||
portfolioForecast = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate monthly growth rate from recent data
|
||||
let recentData = Array(virtualSnapshots.suffix(min(12, virtualSnapshots.count)))
|
||||
guard let firstPoint = recentData.first, let lastPoint = recentData.last,
|
||||
firstPoint.value > 0 else {
|
||||
portfolioForecast = nil
|
||||
return
|
||||
}
|
||||
|
||||
let monthsBetween = max(1, firstPoint.date.monthsBetween(lastPoint.date))
|
||||
let totalGrowth = lastPoint.value - firstPoint.value
|
||||
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
|
||||
|
||||
// Project 12 months ahead
|
||||
let forecastValue = currentValue + (monthlyGrowth * 12)
|
||||
|
||||
// Calculate confidence interval based on volatility
|
||||
let volatility = calculatePortfolioVolatility(from: values)
|
||||
let confidenceWidth = NSDecimalNumber(decimal: currentValue).doubleValue * volatility * 0.5
|
||||
|
||||
portfolioForecast = PortfolioForecast(
|
||||
currentValue: currentValue,
|
||||
forecastValue: forecastValue,
|
||||
forecastDate: Calendar.current.date(byAdding: .month, value: 12, to: Date()) ?? Date(),
|
||||
confidenceLower: Decimal(max(0, NSDecimalNumber(decimal: forecastValue).doubleValue - confidenceWidth)),
|
||||
confidenceUpper: forecastValue + Decimal(confidenceWidth),
|
||||
monthlyGrowthRate: monthlyGrowth,
|
||||
annualizedGrowthRate: calculateAnnualizedGrowth(from: virtualSnapshots)
|
||||
)
|
||||
}
|
||||
|
||||
private func calculatePortfolioVolatility(from values: [Double]) -> Double {
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
var returns: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
guard values[i - 1] != 0 else { continue }
|
||||
let periodReturn = (values[i] - values[i - 1]) / values[i - 1]
|
||||
returns.append(periodReturn)
|
||||
}
|
||||
|
||||
guard !returns.isEmpty else { return 0 }
|
||||
|
||||
let mean = returns.reduce(0, +) / Double(returns.count)
|
||||
let squaredDiffs = returns.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDiffs.reduce(0, +) / Double(max(1, returns.count - 1))
|
||||
return sqrt(variance)
|
||||
}
|
||||
|
||||
private func calculateAnnualizedGrowth(from snapshots: [VirtualSnapshot]) -> Decimal {
|
||||
guard let first = snapshots.first, let last = snapshots.last,
|
||||
first.value > 0 else { return 0 }
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let totalReturn = (last.value - first.value) / first.value
|
||||
|
||||
// Annualize: (1 + total_return)^(12/months) - 1
|
||||
let monthlyReturn = totalReturn / Decimal(monthsBetween)
|
||||
let annualized = monthlyReturn * 12
|
||||
return annualized
|
||||
}
|
||||
|
||||
// Virtual snapshot for forecast calculations
|
||||
private struct VirtualSnapshot {
|
||||
let date: Date
|
||||
let value: Decimal
|
||||
}
|
||||
|
||||
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
|
||||
let target = goal.targetDecimal
|
||||
let lastValue = evolutionData.last?.value ?? currentValue
|
||||
|
||||
Reference in New Issue
Block a user