Base fixes and test harness

This commit is contained in:
alexandrev-tibco
2026-02-01 11:12:57 +01:00
parent f5b13ec924
commit e328767c4a
39 changed files with 3575 additions and 142 deletions
@@ -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
}