d88cc59627
Un Goal ya podía acotarse por cuenta; ahora también por categoría de fuente, de modo que se puede fijar "100.000 € en Cash" y que el progreso cuente solo las fuentes de esa categoría. - CoreData: relación Goal.category ↔ Category.goals (opcional, Nullify) - Goal.includes(_:)/relevantSources(from:)/scopeKey centralizan el filtro de ámbito (cuenta Y categoría) que antes estaba duplicado en GoalsViewModel, AddSourceView y NotificationService - GoalEditorView: sección "Ámbito" con selector de categoría - Chip de categoría en la lista de Goals y en la tarjeta del Dashboard - ETA del Dashboard para goals con ámbito: usa la proyección del propio goal en vez de la evolución global de la cartera (que daría "objetivo alcanzado" en cuanto la cartera total superara el importe) - GoalRepository e ImportService deduplican por (nombre, cuenta, categoría) - Export/Import de goals incluye "category" - Nuevas claves localizadas en los 7 idiomas Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
849 lines
32 KiB
Swift
849 lines
32 KiB
Swift
import Foundation
|
|
import Combine
|
|
import CoreData
|
|
import SwiftUI
|
|
|
|
@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: - Data Gaps
|
|
|
|
/// Internal gaps in per-source snapshot history (missing months between two
|
|
/// known snapshots). The chart smooths these over; this exposes them so the
|
|
/// user knows real data is missing and can fill it. See SnapshotGapDetector.
|
|
@Published var dataGaps: [SnapshotGapDetector.Gap] = []
|
|
|
|
// 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)
|
|
|
|
// Detect internal data gaps (missing months between known snapshots)
|
|
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
|
|
|
|
// NOTE: no screen_view logging here — refreshData() fires on every
|
|
// relevant Core Data change (incl. iCloud imports), so logging from it
|
|
// inflated GA4 with thousands of phantom Dashboard views from devices
|
|
// left open. The view logs once per appearance in DashboardView.onAppear.
|
|
}
|
|
|
|
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 calendar = Calendar.current
|
|
func monthKey(_ date: Date) -> DateComponents {
|
|
let c = calendar.dateComponents([.year, .month], from: date)
|
|
return DateComponents(year: c.year, month: c.month)
|
|
}
|
|
|
|
// Ordered grid of every month that has data, and each month's index in it.
|
|
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
|
|
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
|
|
}
|
|
let monthCount = sortedMonthKeys.count
|
|
guard monthCount > 0 else {
|
|
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
|
}
|
|
var indexByKey: [DateComponents: Int] = [:]
|
|
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
|
|
|
// Per source: the latest snapshot value at each month index + its category.
|
|
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
|
var categoryBySource: [UUID: UUID] = [:]
|
|
for snapshot in snapshots {
|
|
guard let sourceId = snapshot.source?.id,
|
|
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
|
|
let existing = knownBySource[sourceId]?[idx]
|
|
if existing == nil || snapshot.date > existing!.date {
|
|
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
|
}
|
|
if let categoryId = snapshot.source?.category?.id {
|
|
categoryBySource[sourceId] = categoryId
|
|
}
|
|
}
|
|
|
|
// Fill each source's gaps (linear interpolation when enabled, else stepped
|
|
// carry-forward), then sum per month. Interpolating per source means a
|
|
// source last seen in Feb and next in Jun contributes a smooth ramp for
|
|
// Mar/Apr/May instead of a flat line + jump. Chart display only — no data
|
|
// is created (see ChartGapFill).
|
|
let interpolate = ChartGapFill.isEnabled
|
|
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
|
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
|
|
var categoryTotals: [UUID: Decimal] = [:]
|
|
|
|
for (sourceId, known) in knownBySource {
|
|
let dense = ChartGapFill.denseValues(
|
|
known: known.mapValues { $0.value },
|
|
monthCount: monthCount,
|
|
interpolate: interpolate
|
|
)
|
|
let categoryId = categoryBySource[sourceId]
|
|
for i in 0..<monthCount {
|
|
guard let v = dense[i] else { continue }
|
|
monthTotals[i] += v
|
|
if let categoryId {
|
|
monthCategory[i][categoryId, default: 0] += v
|
|
categoryTotals[categoryId, default: 0] += v
|
|
}
|
|
}
|
|
}
|
|
|
|
var evolution: [(date: Date, value: Decimal)] = []
|
|
evolution.reserveCapacity(monthCount)
|
|
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
|
series.reserveCapacity(monthCount)
|
|
for i in 0..<monthCount {
|
|
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
|
|
evolution.append((date: date, value: monthTotals[i]))
|
|
series.append((date: date, valuesByCategory: monthCategory[i]))
|
|
}
|
|
|
|
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 }
|
|
// A month is "complete" if all active sources have snapshot data for it.
|
|
// We intentionally do NOT require a MonthlyCheckInStore entry here because
|
|
// that store lives in UserDefaults (device-local) and is not synced via iCloud.
|
|
// Snapshot data from CoreData is the authoritative cross-device source of truth.
|
|
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 using compound growth (matches CAGR semantics);
|
|
// fall back to the linear projection if the ratio is degenerate.
|
|
let firstD = NSDecimalNumber(decimal: firstPoint.value).doubleValue
|
|
let lastD = NSDecimalNumber(decimal: lastPoint.value).doubleValue
|
|
let currentD = NSDecimalNumber(decimal: currentValue).doubleValue
|
|
let forecastValue: Decimal
|
|
if firstD > 0, lastD > 0 {
|
|
let monthlyRate = pow(lastD / firstD, 1.0 / Double(monthsBetween))
|
|
let projected = currentD * pow(monthlyRate, 12)
|
|
forecastValue = projected.isFinite ? Decimal(projected) : currentValue + (monthlyGrowth * 12)
|
|
} else {
|
|
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 compounding: (1 + total_return)^(12/months) - 1.
|
|
// The previous linear approximation (monthly*12) overstated short histories.
|
|
let totalReturnD = NSDecimalNumber(decimal: totalReturn).doubleValue
|
|
guard totalReturnD > -1 else { return 0 }
|
|
let annualizedD = pow(1 + totalReturnD, 12.0 / Double(monthsBetween)) - 1
|
|
guard annualizedD.isFinite else { return 0 }
|
|
return Decimal(annualizedD)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// ETA text for a goal scoped to an account and/or a category. The dashboard
|
|
/// evolution covers the whole (selected) portfolio, so a scoped goal — e.g.
|
|
/// "100k in Cash" — must be projected over its own sources instead; the
|
|
/// caller passes that projection in.
|
|
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
|
|
if currentValue >= goal.targetDecimal {
|
|
return "Goal reached. Keep it steady."
|
|
}
|
|
guard let months = projection.monthsToReach else {
|
|
return "Keep going. Consistency pays off."
|
|
}
|
|
if months == 0 {
|
|
return "Almost there. One more check-in."
|
|
}
|
|
if months >= 72 {
|
|
return "Long journey, strong discipline. You're building momentum."
|
|
}
|
|
if let projectedDate = projection.projectedDate {
|
|
return "Estimated: \(projectedDate.monthYearString)"
|
|
}
|
|
return "Estimated: \(months) months"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Total number of missing months across all detected internal gaps.
|
|
var totalMissingMonths: Int {
|
|
SnapshotGapDetector.totalMissingMonths(dataGaps)
|
|
}
|
|
|
|
/// Resolves an InvestmentSource for a detected gap so the fill flow can open
|
|
/// the add-snapshot sheet for it.
|
|
func source(withId id: UUID) -> InvestmentSource? {
|
|
sourceRepository.sources.first { $0.id == id }
|
|
}
|
|
|
|
/// Builds the data for the shareable monthly summary card from already-computed
|
|
/// dashboard state. Mood/rating come from the latest completed check-in entry.
|
|
func monthlySummaryShareData() -> ShareService.MonthlySummaryShareData {
|
|
let monthLabel: String = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMMM yyyy"
|
|
let date = MonthlyCheckInStore.latestCompletionDate() ?? Date()
|
|
return formatter.string(from: date)
|
|
}()
|
|
|
|
let latestEntry = MonthlyCheckInStore.latestCompletionDate()
|
|
.flatMap { MonthlyCheckInStore.entry(for: $0) }
|
|
|
|
return ShareService.MonthlySummaryShareData(
|
|
monthLabel: monthLabel,
|
|
totalValue: portfolioSummary.formattedTotalValue,
|
|
changeAmount: latestPortfolioChange.formattedAbsolute,
|
|
changePercentage: latestPortfolioChange.formattedPercentage,
|
|
isPositive: latestPortfolioChange.absolute >= 0,
|
|
streak: updateStreak,
|
|
mood: latestEntry?.mood,
|
|
rating: latestEntry?.rating
|
|
)
|
|
}
|
|
|
|
/// Prioritized, on-device insights. Gathers local inputs and delegates the
|
|
/// rules to `InsightsEngine` (no network / no AI).
|
|
var insights: [PortfolioInsight] {
|
|
let total = portfolioSummary.totalValue
|
|
guard total > 0 else { return [] }
|
|
|
|
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
|
|
InsightsEngine.CategorySlice(
|
|
name: cat.categoryName,
|
|
percentageOfPortfolio: cat.percentageOfPortfolio,
|
|
targetPercentage: AllocationTargetStore.target(for: cat.id),
|
|
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
|
|
)
|
|
}
|
|
|
|
let sources = filteredSources()
|
|
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
|
|
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
|
|
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
|
|
guard value > 0, totalDouble > 0 else { return nil }
|
|
return InsightsEngine.SourceSlice(
|
|
name: source.name,
|
|
percentageOfPortfolio: value / totalDouble * 100
|
|
)
|
|
}
|
|
|
|
let input = InsightsEngine.Input(
|
|
totalValue: total,
|
|
categories: categorySlices,
|
|
sources: sourceSlices,
|
|
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
|
|
fundedSourceCount: sourceSlices.count,
|
|
yearChangePercentage: portfolioSummary.yearChangePercentage,
|
|
allTimeReturn: portfolioSummary.allTimeReturn,
|
|
updateStreak: updateStreak,
|
|
forecast: portfolioForecast
|
|
)
|
|
|
|
return InsightsEngine.makeInsights(input)
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
/// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card — an
|
|
/// outsider needs a concrete date, not "2 days ago".
|
|
var shareAsOfDate: String? {
|
|
guard let date = portfolioSummary.lastUpdated else { return nil }
|
|
let f = DateFormatter()
|
|
f.locale = .autoupdatingCurrent
|
|
f.dateStyle = .medium
|
|
f.timeStyle = .none
|
|
return f.string(from: date)
|
|
}
|
|
}
|