Primera build enviada
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
@MainActor
|
||||
class ChartsViewModel: ObservableObject {
|
||||
@@ -149,7 +150,6 @@ class ChartsViewModel: ObservableObject {
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
private var cachedSnapshots: [Snapshot]?
|
||||
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
|
||||
private var isUpdateInProgress = false
|
||||
|
||||
// MARK: - Initialization
|
||||
@@ -186,20 +186,20 @@ class ChartsViewModel: ObservableObject {
|
||||
let (chartType, category, timeRange, _) = combined
|
||||
|
||||
// Performance: Skip update if nothing meaningful changed
|
||||
let safeSelectedAccountId = self.safeSelectedAccountId
|
||||
let hasChanges = self.lastChartType != chartType ||
|
||||
self.lastTimeRange != timeRange ||
|
||||
self.lastCategoryId != category?.id ||
|
||||
self.lastAccountId != self.selectedAccount?.id ||
|
||||
self.lastAccountId != safeSelectedAccountId ||
|
||||
self.lastShowAllAccounts != showAll
|
||||
|
||||
if hasChanges {
|
||||
self.lastChartType = chartType
|
||||
self.lastTimeRange = timeRange
|
||||
self.lastCategoryId = category?.id
|
||||
self.lastAccountId = self.selectedAccount?.id
|
||||
self.lastAccountId = safeSelectedAccountId
|
||||
self.lastShowAllAccounts = showAll
|
||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||
self.cachedSnapshotsBySource = nil
|
||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||
}
|
||||
}
|
||||
@@ -266,48 +266,40 @@ class ChartsViewModel: ObservableObject {
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
// Performance: Cache snapshotsBySource
|
||||
let snapshotsBySource: [UUID: [Snapshot]]
|
||||
if let cached = cachedSnapshotsBySource {
|
||||
snapshotsBySource = cached
|
||||
} else {
|
||||
var grouped: [UUID: [Snapshot]] = [:]
|
||||
grouped.reserveCapacity(sources.count)
|
||||
for snapshot in snapshots {
|
||||
guard let id = snapshot.source?.id else { continue }
|
||||
grouped[id, default: []].append(snapshot)
|
||||
}
|
||||
snapshotsBySource = grouped
|
||||
cachedSnapshotsBySource = grouped
|
||||
}
|
||||
let completedSnapshots = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: snapshots
|
||||
)
|
||||
|
||||
// Performance: Only calculate data for the selected chart type
|
||||
switch chartType {
|
||||
case .evolution:
|
||||
calculateEvolutionData(from: snapshots)
|
||||
calculateEvolutionData(from: completedSnapshots)
|
||||
let categoriesForChart = categoriesForStackedChart(
|
||||
sources: sources,
|
||||
selectedCategory: selectedCategory
|
||||
)
|
||||
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources)
|
||||
case .performance:
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
case .contributions:
|
||||
calculateContributionsData(from: snapshots)
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .rollingReturn:
|
||||
calculateRollingReturnData(from: snapshots)
|
||||
calculateRollingReturnData(from: completedSnapshots)
|
||||
case .riskReturn:
|
||||
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculateRiskReturnData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
case .cashflow:
|
||||
calculateCashflowData(from: snapshots)
|
||||
calculateCashflowData(from: completedSnapshots)
|
||||
case .drawdown:
|
||||
calculateDrawdownData(from: snapshots)
|
||||
calculateDrawdownData(from: completedSnapshots)
|
||||
case .volatility:
|
||||
calculateVolatilityData(from: snapshots)
|
||||
calculateVolatilityData(from: completedSnapshots)
|
||||
case .prediction:
|
||||
calculatePredictionData(from: snapshots)
|
||||
calculatePredictionData(from: completedSnapshots)
|
||||
}
|
||||
|
||||
if let selected = selectedCategory,
|
||||
@@ -336,7 +328,17 @@ class ChartsViewModel: ObservableObject {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return true
|
||||
}
|
||||
return source.account?.id == selectedAccount?.id
|
||||
guard let selectedId = safeSelectedAccountId else { return true }
|
||||
return source.account?.id == selectedId
|
||||
}
|
||||
|
||||
private var safeSelectedAccountId: UUID? {
|
||||
guard !showAllAccounts,
|
||||
let selected = selectedAccount,
|
||||
!selected.isDeleted else {
|
||||
return nil
|
||||
}
|
||||
return selected.id
|
||||
}
|
||||
|
||||
private func categoriesForStackedChart(
|
||||
@@ -406,17 +408,34 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
|
||||
var dateValues: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
dateValues[day, default: 0] += snapshot.decimalValue
|
||||
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)
|
||||
}
|
||||
|
||||
let series = dateValues
|
||||
.map { (date: $0.key, value: $0.value) }
|
||||
.sorted { $0.date < $1.date }
|
||||
var series: [(date: Date, value: Decimal)] = []
|
||||
series.reserveCapacity(groupedByMonth.count)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
series.append((date: date, value: total))
|
||||
}
|
||||
|
||||
series.sort { $0.date < $1.date }
|
||||
|
||||
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||
}
|
||||
|
||||
@@ -477,6 +496,69 @@ class ChartsViewModel: ObservableObject {
|
||||
}.sorted { $0.value > $1.value }
|
||||
}
|
||||
|
||||
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 groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] {
|
||||
var grouped: [UUID: [Snapshot]] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let id = snapshot.source?.id else { continue }
|
||||
grouped[id, default: []].append(snapshot)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
private func calculatePerformanceData(
|
||||
for sources: [InvestmentSource],
|
||||
snapshotsBySource: [UUID: [Snapshot]]
|
||||
@@ -492,11 +574,20 @@ class ChartsViewModel: ObservableObject {
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: metrics.cagr,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
@@ -543,12 +634,23 @@ class ChartsViewModel: ObservableObject {
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 3 else { return nil }
|
||||
let metrics = calculationService.calculateMetrics(for: snapshots)
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard monthlyTotals.count >= 3,
|
||||
let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
let monthlyReturns = monthlyReturnSeries(from: monthlyTotals)
|
||||
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: metrics.cagr,
|
||||
volatility: metrics.volatility,
|
||||
cagr: cagr,
|
||||
volatility: volatility,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
@@ -670,6 +772,61 @@ class ChartsViewModel: ObservableObject {
|
||||
return totals
|
||||
}
|
||||
|
||||
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
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 totals: [(date: Date, totalValue: Decimal)] = []
|
||||
totals.reserveCapacity(groupedByMonth.count)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
totals.append((date: date, totalValue: total))
|
||||
}
|
||||
|
||||
return totals.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private func monthlyReturnSeries(
|
||||
from monthlyTotals: [(date: Date, totalValue: Decimal)]
|
||||
) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
guard monthlyTotals.count >= 2 else { return [] }
|
||||
|
||||
var returns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
returns.reserveCapacity(monthlyTotals.count - 1)
|
||||
|
||||
for index in 1..<monthlyTotals.count {
|
||||
let previous = monthlyTotals[index - 1]
|
||||
let current = monthlyTotals[index]
|
||||
guard previous.totalValue > 0 else { continue }
|
||||
let returnPercentage = NSDecimalNumber(
|
||||
decimal: (current.totalValue - previous.totalValue) / previous.totalValue
|
||||
).doubleValue * 100
|
||||
returns.append(InvestmentMetrics.MonthlyReturn(
|
||||
date: current.date,
|
||||
returnPercentage: returnPercentage
|
||||
))
|
||||
}
|
||||
|
||||
return returns
|
||||
}
|
||||
|
||||
func updatePredictionTargetDate(_ goals: [Goal]) {
|
||||
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
|
||||
guard let latestGoalDate = futureGoalDates.max(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,13 +21,13 @@ class GoalsViewModel: ObservableObject {
|
||||
private var lastSourcesHash: Int = 0
|
||||
|
||||
init(
|
||||
goalRepository: GoalRepository = GoalRepository(),
|
||||
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
|
||||
snapshotRepository: SnapshotRepository = SnapshotRepository()
|
||||
goalRepository: GoalRepository? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
snapshotRepository: SnapshotRepository? = nil
|
||||
) {
|
||||
self.goalRepository = goalRepository
|
||||
self.sourceRepository = sourceRepository
|
||||
self.snapshotRepository = snapshotRepository
|
||||
self.goalRepository = goalRepository ?? GoalRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
|
||||
setupObservers()
|
||||
refresh()
|
||||
@@ -69,9 +69,9 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func totalValue(for goal: Goal) -> Decimal {
|
||||
if let account = goal.account {
|
||||
if let accountId = goal.account?.safeId {
|
||||
return sourceRepository.sources
|
||||
.filter { $0.account?.id == account.id }
|
||||
.filter { $0.account?.id == accountId }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
return totalValue
|
||||
@@ -143,8 +143,8 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
if let account = goal.account {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
|
||||
if let accountId = goal.account?.safeId {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
}
|
||||
@@ -155,7 +155,7 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
// Performance: Use cached evolution data if available
|
||||
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
evolutionData = cached
|
||||
@@ -251,7 +251,8 @@ class GoalsViewModel: ObservableObject {
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func loadGoals() {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
let selectedAccountId = selectedAccount?.safeId
|
||||
if showAllAccounts || selectedAccountId == nil {
|
||||
goalRepository.fetchGoals()
|
||||
} else if let account = selectedAccount {
|
||||
goalRepository.fetchGoals(for: account)
|
||||
@@ -259,27 +260,25 @@ class GoalsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateGoals(using repositoryGoals: [Goal]) {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
let selectedAccountId = selectedAccount?.safeId
|
||||
if showAllAccounts || selectedAccountId == nil {
|
||||
goals = repositoryGoals
|
||||
} else if let account = selectedAccount {
|
||||
goals = repositoryGoals.filter { $0.account?.id == account.id }
|
||||
} else {
|
||||
goals = repositoryGoals
|
||||
goals = repositoryGoals.filter { $0.account?.id == selectedAccountId }
|
||||
}
|
||||
updateTotalValue()
|
||||
}
|
||||
|
||||
private func updateTotalValue() {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
let selectedAccountId = selectedAccount?.safeId
|
||||
if showAllAccounts || selectedAccountId == nil {
|
||||
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
return
|
||||
}
|
||||
|
||||
if let account = selectedAccount {
|
||||
totalValue = sourceRepository.sources
|
||||
.filter { $0.account?.id == account.id }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
totalValue = sourceRepository.sources
|
||||
.filter { $0.account?.id == selectedAccountId }
|
||||
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,10 +112,11 @@ class MonthlyCheckInViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func filteredSources() -> [InvestmentSource] {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
let selectedAccountId = selectedAccount?.safeId
|
||||
if showAllAccounts || selectedAccountId == nil {
|
||||
return sourceRepository.sources
|
||||
}
|
||||
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
|
||||
return sourceRepository.sources.filter { $0.account?.id == selectedAccountId }
|
||||
}
|
||||
|
||||
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
|
||||
|
||||
@@ -83,14 +83,28 @@ class SettingsViewModel: ObservableObject {
|
||||
currencyCode = settings.currency
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
|
||||
// Load statistics
|
||||
totalSources = sourceRepository.sourceCount
|
||||
totalCategories = categoryRepository.categories.count
|
||||
totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount }
|
||||
// Load statistics directly from database to avoid async race conditions
|
||||
loadStatistics()
|
||||
|
||||
FirebaseService.shared.logScreenView(screenName: "Settings")
|
||||
}
|
||||
|
||||
private func loadStatistics() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
|
||||
// Fetch source count directly
|
||||
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
totalSources = (try? context.count(for: sourceRequest)) ?? 0
|
||||
|
||||
// Fetch category count directly
|
||||
let categoryRequest: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
totalCategories = (try? context.count(for: categoryRequest)) ?? 0
|
||||
|
||||
// Fetch snapshot count directly
|
||||
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
totalSnapshots = (try? context.count(for: snapshotRequest)) ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Premium Actions
|
||||
|
||||
func upgradeToPremium() {
|
||||
@@ -142,6 +156,16 @@ class SettingsViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
@Published var isExporting = false
|
||||
@Published var exportProgress: Double = 0
|
||||
@Published var exportStatus = ""
|
||||
@Published var shareItem: ShareItem?
|
||||
|
||||
struct ShareItem: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
func exportData(format: ExportService.ExportFormat) {
|
||||
guard freemiumValidator.canExport() else {
|
||||
showingPaywall = true
|
||||
@@ -149,19 +173,79 @@ class SettingsViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
isExporting = true
|
||||
exportProgress = 0
|
||||
exportStatus = "Preparing export..."
|
||||
|
||||
ExportService.shared.share(
|
||||
format: format,
|
||||
sources: sourceRepository.sources,
|
||||
categories: categoryRepository.categories,
|
||||
from: viewController
|
||||
)
|
||||
Task {
|
||||
// Fetch data directly from database
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let sources = fetchAllSources(in: context)
|
||||
let categories = fetchAllCategories(in: context)
|
||||
|
||||
await MainActor.run {
|
||||
exportProgress = 0.3
|
||||
exportStatus = "Generating \(format.rawValue) file..."
|
||||
}
|
||||
|
||||
let content: String
|
||||
let fileName: String
|
||||
|
||||
switch format {
|
||||
case .csv:
|
||||
content = ExportService.shared.exportToCSV(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.csv"
|
||||
case .json:
|
||||
content = ExportService.shared.exportToJSON(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.json"
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
exportProgress = 0.7
|
||||
exportStatus = "Saving file..."
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
await MainActor.run {
|
||||
exportProgress = 1.0
|
||||
exportStatus = "Export complete"
|
||||
isExporting = false
|
||||
showingExportOptions = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
self?.shareItem = ShareItem(url: tempURL)
|
||||
}
|
||||
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
errorMessage = "Export failed: \(error.localizedDescription)"
|
||||
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
// Share sheet is presented in SettingsView using shareItem.
|
||||
|
||||
var canExport: Bool {
|
||||
freemiumValidator.canExport()
|
||||
}
|
||||
@@ -202,41 +286,79 @@ class SettingsViewModel: ObservableObject {
|
||||
// MARK: - Data Management
|
||||
|
||||
func resetAllData() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let entityNames = [
|
||||
"Snapshot",
|
||||
"InvestmentSource",
|
||||
"Category",
|
||||
"Asset",
|
||||
"Account",
|
||||
"Goal",
|
||||
"Transaction",
|
||||
"PredictionCache"
|
||||
]
|
||||
|
||||
// Delete all snapshots, sources, and categories
|
||||
for source in sourceRepository.sources {
|
||||
context.delete(source)
|
||||
}
|
||||
for category in categoryRepository.categories {
|
||||
context.delete(category)
|
||||
}
|
||||
let assetRequest: NSFetchRequest<Asset> = Asset.fetchRequest()
|
||||
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let assets = try? context.fetch(assetRequest) {
|
||||
assets.forEach { context.delete($0) }
|
||||
}
|
||||
if let accounts = try? context.fetch(accountRequest) {
|
||||
accounts.forEach { context.delete($0) }
|
||||
}
|
||||
if let goals = try? context.fetch(goalRequest) {
|
||||
goals.forEach { context.delete($0) }
|
||||
context.performAndWait {
|
||||
var deletedObjectIDs: [NSManagedObjectID] = []
|
||||
for name in entityNames {
|
||||
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: name)
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
deleteRequest.resultType = .resultTypeObjectIDs
|
||||
if let result = try? context.execute(deleteRequest) as? NSBatchDeleteResult,
|
||||
let objectIDs = result.result as? [NSManagedObjectID] {
|
||||
deletedObjectIDs.append(contentsOf: objectIDs)
|
||||
}
|
||||
}
|
||||
|
||||
if !deletedObjectIDs.isEmpty {
|
||||
NSManagedObjectContext.mergeChanges(
|
||||
fromRemoteContextSave: [NSDeletedObjectsKey: deletedObjectIDs],
|
||||
into: [context]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CoreDataStack.shared.save()
|
||||
context.reset()
|
||||
|
||||
// Clear notifications
|
||||
context.performAndWait {
|
||||
// Recreate default categories
|
||||
let categoryCount = (try? context.count(for: Category.fetchRequest())) ?? 0
|
||||
if categoryCount == 0 {
|
||||
Category.createDefaultCategories(in: context)
|
||||
}
|
||||
|
||||
// Recreate default account
|
||||
let accountCount = (try? context.count(for: Account.fetchRequest())) ?? 0
|
||||
if accountCount == 0 {
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = Account(context: context)
|
||||
account.name = Account.defaultAccountName
|
||||
account.currency = defaultCurrency
|
||||
account.inputMode = InputMode.simple.rawValue
|
||||
account.notificationFrequency = NotificationFrequency.monthly.rawValue
|
||||
account.customFrequencyMonths = 1
|
||||
account.sortOrder = 0
|
||||
}
|
||||
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.selectedAccountId = nil
|
||||
settings.showAllAccounts = true
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
// Clear notifications + monthly check-ins
|
||||
notificationService.cancelAllReminders()
|
||||
MonthlyCheckInStore.clearAll()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
|
||||
// Recreate default categories
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
_ = AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Reload data
|
||||
loadSettings()
|
||||
|
||||
isLoading = false
|
||||
successMessage = "All data has been reset"
|
||||
NotificationCenter.default.post(name: .didResetData, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -36,6 +36,7 @@ class SourceDetailViewModel: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -97,15 +98,21 @@ class SourceDetailViewModel: 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
|
||||
|
||||
// Fetch snapshots (filtered by freemium limits)
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||
|
||||
// Check for cancellation before updating UI
|
||||
guard !Task.isCancelled else { break }
|
||||
|
||||
// Performance: Only update if data actually changed
|
||||
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
|
||||
filteredSnapshots.first?.date != self.snapshots.first?.date
|
||||
@@ -139,6 +146,12 @@ class SourceDetailViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel any ongoing background tasks - call when view disappears
|
||||
func cancelPendingTasks() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
// MARK: - Snapshot Actions
|
||||
|
||||
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
|
||||
@@ -288,21 +301,22 @@ class SourceDetailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var isHistoryLimited: Bool {
|
||||
!freemiumValidator.isPremium && hiddenSnapshotCount > 0
|
||||
!freemiumValidator.isPremium
|
||||
}
|
||||
|
||||
var hiddenSnapshotCount: Int {
|
||||
guard let limit = snapshotDisplayLimit else { return 0 }
|
||||
return max(0, snapshots.count - min(limit, snapshots.count))
|
||||
}
|
||||
// For free users, count how many snapshots are hidden due to the 12-month limit
|
||||
guard !freemiumValidator.isPremium else { return 0 }
|
||||
|
||||
var snapshotDisplayLimit: Int? {
|
||||
freemiumValidator.isPremium ? nil : 10
|
||||
// Get all snapshots without the date filter
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
||||
return max(0, allSnapshots.count - snapshots.count)
|
||||
}
|
||||
|
||||
var visibleSnapshots: [Snapshot] {
|
||||
guard let limit = snapshotDisplayLimit else { return snapshots }
|
||||
return Array(snapshots.prefix(limit))
|
||||
// Premium users see all snapshots (already filtered by freemiumValidator in refreshData)
|
||||
// Free users also see all their filtered snapshots (limited to last 12 months)
|
||||
snapshots
|
||||
}
|
||||
|
||||
private func isRelevantChange(_ notification: Notification) -> Bool {
|
||||
|
||||
@@ -83,8 +83,9 @@ class SourceListViewModel: ObservableObject {
|
||||
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
|
||||
var filtered = allSources
|
||||
|
||||
if !showAllAccounts, let account = selectedAccount {
|
||||
filtered = filtered.filter { $0.account?.id == account.id }
|
||||
let selectedAccountId = selectedAccount?.safeId
|
||||
if !showAllAccounts, let selectedAccountId {
|
||||
filtered = filtered.filter { $0.account?.id == selectedAccountId }
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
|
||||
Reference in New Issue
Block a user