Primera build enviada

This commit is contained in:
2026-01-19 14:40:43 +01:00
parent c6be398e5a
commit b03d35194f
36 changed files with 1641 additions and 561 deletions
@@ -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