Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/DashboardViewModel.swift
T
alexandrev-tibco b48a47ce10 Release 1.4.0 (build 31): retención, quick update, streak, what's new
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes)
- 1B: Badge de racha mensual en Dashboard (streak counter)
- 1C: Empty states mejorados en Goals y Journal con CTAs claros
- 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla
- 2B: Notificación batch update redirige al Quick Update sheet
- 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update
- 3A: App Store version check banner en Settings
- 3C: What's New sheet en primer launch de versión nueva
- Localización completa en 7 idiomas para todas las nuevas strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:09:36 +02:00

684 lines
25 KiB
Swift

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
@Published var showingPaywall = false
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var updateStreak: Int = 0
// 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
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
self.predictionEngine = .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
// Cancel any previous refresh task to avoid stale updates
refreshTask?.cancel()
refreshTask = Task { [weak self] in
guard let self else { return }
while self.refreshQueued && !Task.isCancelled {
self.refreshQueued = false
await self.refreshAllData()
}
self.isRefreshing = false
if updateLoadingFlag {
self.isLoading = false
}
}
}
/// 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()
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 (only completed monthly check-ins)
let completedSnapshots = filterSnapshotsForCharts(
sources: sources,
snapshots: allSnapshots
)
updateEvolutionData(from: completedSnapshots, categories: categories)
latestPortfolioChange = calculateLatestCheckInChange(
sources: sources,
snapshots: allSnapshots,
fallback: evolutionData
)
// Calculate portfolio forecast
updatePortfolioForecast()
// Compute update streak
updateStreak = computeStreak(from: evolutionData)
// 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 != safeSelectedAccountId || lastShowAllAccounts != showAllAccounts
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
return cachedFilteredSources!
}
lastAccountId = safeSelectedAccountId
lastShowAllAccounts = showAllAccounts
cachedSourcesHash = currentHash
if showAllAccounts || safeSelectedAccountId == nil {
cachedFilteredSources = sourceRepository.sources
} else {
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == safeSelectedAccountId }
}
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: [:])
}
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
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)
}
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 (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
}
}
var total: Decimal = 0
var valuesByCategory: [UUID: Decimal] = [:]
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,
categoryTotals: categoryTotals
)
}
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 snapshots
}
let completedMonthsAfter = completedMonthKeys(
sources: sources,
snapshots: snapshots,
after: lastCompleted
)
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
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 check-in")
}
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 check-in")
}
private func calculateLatestCheckInChange(
sources: [InvestmentSource],
snapshots: [Snapshot],
fallback: [(date: Date, value: Decimal)]
) -> PortfolioChange {
let completedMonths = MonthlyCheckInStore.allEntries()
.compactMap { entry -> Date? in
entry.entry.completionDate != nil ? entry.date.startOfMonth : nil
}
.sorted()
guard completedMonths.count >= 2 else {
return calculateLatestChange(from: fallback)
}
let lastMonth = completedMonths[completedMonths.count - 1]
let previousMonth = completedMonths[completedMonths.count - 2]
let lastCompletion = MonthlyCheckInStore.completionDate(for: lastMonth) ?? lastMonth.endOfMonth
let previousCompletion = MonthlyCheckInStore.completionDate(for: previousMonth) ?? previousMonth.endOfMonth
let lastValue = totalPortfolioValue(asOf: lastCompletion, sources: sources, snapshots: snapshots)
let previousValue = totalPortfolioValue(asOf: previousCompletion, sources: sources, snapshots: snapshots)
let absolute = lastValue - previousValue
let percentage = previousValue > 0
? NSDecimalNumber(decimal: absolute / previousValue).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
}
private func totalPortfolioValue(
asOf date: Date,
sources: [InvestmentSource],
snapshots: [Snapshot]
) -> Decimal {
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var total = Decimal.zero
for source in sources {
let sourceId = source.id
guard let sourceSnapshots = snapshotsBySource[sourceId] else { continue }
if let latest = sourceSnapshots
.filter({ $0.date <= date })
.max(by: { $0.date < $1.date }) {
total += latest.decimalValue
}
}
return total
}
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
}
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
guard !evolutionData.isEmpty else { return 0 }
let calendar = Calendar.current
// Group into (year, month) set
let monthSet: Set<DateComponents> = Set(evolutionData.map { point in
let comps = calendar.dateComponents([.year, .month], from: point.date)
return DateComponents(year: comps.year, month: comps.month)
})
// Get sorted unique months descending
let sortedMonths = monthSet
.compactMap { comps -> Date? in calendar.date(from: comps) }
.sorted(by: >)
guard let mostRecent = sortedMonths.first else { return 0 }
var streak = 1
var current = mostRecent
for i in 1..<sortedMonths.count {
guard let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonths[i]
let prevComps = calendar.dateComponents([.year, .month], from: prev)
let expectedComps = calendar.dateComponents([.year, .month], from: expected)
if prevComps.year == expectedComps.year && prevComps.month == expectedComps.month {
streak += 1
current = prev
} else {
break
}
}
return streak
}
// 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
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"
}
}