Base fixes and test harness
This commit is contained in:
@@ -530,7 +530,7 @@ class ChartsViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -541,6 +541,10 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -195,7 +195,11 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
updateEvolutionData(from: completedSnapshots, categories: categories)
|
||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||
latestPortfolioChange = calculateLatestCheckInChange(
|
||||
sources: sources,
|
||||
snapshots: allSnapshots,
|
||||
fallback: evolutionData
|
||||
)
|
||||
|
||||
// Calculate portfolio forecast
|
||||
updatePortfolioForecast()
|
||||
@@ -360,7 +364,7 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -371,6 +375,10 @@ class DashboardViewModel: ObservableObject {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -382,7 +390,7 @@ class DashboardViewModel: ObservableObject {
|
||||
|
||||
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")
|
||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last check-in")
|
||||
}
|
||||
let last = data[data.count - 1]
|
||||
let previous = data[data.count - 2]
|
||||
@@ -390,7 +398,58 @@ class DashboardViewModel: ObservableObject {
|
||||
let percentage = previous.value > 0
|
||||
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
|
||||
: 0
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||
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() {
|
||||
|
||||
@@ -12,6 +12,7 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
@Published var includeContribution = false
|
||||
@Published var inputMode: InputMode = .simple
|
||||
@Published var currencySymbol = "€"
|
||||
private let currencyCode: String
|
||||
|
||||
@Published var isValid = false
|
||||
@Published var errorMessage: String?
|
||||
@@ -37,8 +38,10 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
self.mode = mode
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
|
||||
currencyCode = accountCurrency
|
||||
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
|
||||
} else {
|
||||
currencyCode = settings.currency
|
||||
currencySymbol = settings.currencySymbol
|
||||
}
|
||||
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
|
||||
@@ -99,23 +102,49 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let decimalSeparator = locale.decimalSeparator ?? "."
|
||||
let usesAlternateDecimal =
|
||||
(decimalSeparator == "," && cleaned.contains(".") && !cleaned.contains(",")) ||
|
||||
(decimalSeparator == "." && cleaned.contains(",") && !cleaned.contains("."))
|
||||
|
||||
if usesAlternateDecimal {
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.locale = locale
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
if let result = formatter.number(from: cleaned)?.decimalValue {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback for mixed locale input
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
private func formatDecimalForInput(_ decimal: Decimal) -> String {
|
||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = locale
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.groupingSeparator = ""
|
||||
|
||||
@@ -12,6 +12,7 @@ class SourceDetailViewModel: ObservableObject {
|
||||
@Published var predictions: [Prediction] = []
|
||||
@Published var predictionResult: PredictionResult?
|
||||
@Published var transactions: [Transaction] = []
|
||||
@Published var isDeleted = false
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingAddSnapshot = false
|
||||
@@ -37,6 +38,7 @@ class SourceDetailViewModel: ObservableObject {
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private let sourceName: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -50,6 +52,7 @@ class SourceDetailViewModel: ObservableObject {
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.source = source
|
||||
self.sourceName = source.name
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.transactionRepository = transactionRepository ?? TransactionRepository()
|
||||
@@ -103,6 +106,11 @@ class SourceDetailViewModel: ObservableObject {
|
||||
|
||||
refreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
|
||||
self.isDeleted = true
|
||||
self.isRefreshing = false
|
||||
return
|
||||
}
|
||||
while self.refreshQueued && !Task.isCancelled {
|
||||
self.refreshQueued = false
|
||||
|
||||
@@ -232,6 +240,23 @@ class SourceDetailViewModel: ObservableObject {
|
||||
showingEditSource = false
|
||||
}
|
||||
|
||||
func deleteSource() {
|
||||
guard !isDeleted else { return }
|
||||
// Mark deleted first so the view can dismiss before any further access.
|
||||
isDeleted = true
|
||||
snapshots = []
|
||||
chartData = []
|
||||
predictionResult = nil
|
||||
// Cancel any pending notifications for this source
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
|
||||
// Log analytics before deletion
|
||||
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
|
||||
|
||||
// Delete the source using the existing repository
|
||||
sourceRepository.deleteSource(source)
|
||||
}
|
||||
|
||||
// MARK: - Predictions
|
||||
|
||||
func showPredictions() {
|
||||
@@ -256,6 +281,13 @@ class SourceDetailViewModel: ObservableObject {
|
||||
currentValue.currencyString
|
||||
}
|
||||
|
||||
var safeSourceName: String {
|
||||
if source.isDeleted || source.managedObjectContext == nil {
|
||||
return sourceName
|
||||
}
|
||||
return source.name
|
||||
}
|
||||
|
||||
var totalReturn: Decimal {
|
||||
metrics.absoluteReturn
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user