Primera build enviada
This commit is contained in:
@@ -30,6 +30,12 @@ public class Account: NSManagedObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
extension Account {
|
||||
static let defaultAccountName = "Default"
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Account {
|
||||
@@ -50,5 +56,9 @@ extension Account {
|
||||
var currencyCode: String? {
|
||||
currency?.isEmpty == false ? currency : nil
|
||||
}
|
||||
|
||||
var isDefaultAccount: Bool {
|
||||
name == Account.defaultAccountName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,13 +69,13 @@ extension Category {
|
||||
extension Category {
|
||||
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
|
||||
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
|
||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||
("Real Estate", "#F59E0B", "house.fill"),
|
||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||
("Cash", "#6B7280", "banknote.fill"),
|
||||
("ETFs", "#EC4899", "chart.bar.fill"),
|
||||
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
|
||||
("Real Estate", "#F59E0B", "house.fill"),
|
||||
("Bonds", "#3B82F6", "building.columns.fill"),
|
||||
("Retirement", "#14B8A6", "person.fill"),
|
||||
("Other", "#64748B", "ellipsis.circle.fill")
|
||||
("Other", "#64748B", "ellipsis.circle.fill"),
|
||||
("ETFs", "#EC4899", "chart.bar.fill")
|
||||
]
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
|
||||
@@ -179,6 +179,55 @@ class CoreDataStack: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Cleanup Duplicates
|
||||
|
||||
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
||||
/// This fixes data corruption from race conditions during object creation.
|
||||
func cleanupDuplicateObjects() {
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
// Clean up duplicate Goals
|
||||
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
|
||||
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||
// Clean up duplicate InvestmentSources
|
||||
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||
// Clean up duplicate Categories
|
||||
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||
|
||||
guard let objects = try? context.fetch(request) else { return }
|
||||
|
||||
var seenIds = Set<UUID>()
|
||||
var objectsToDelete: [NSManagedObject] = []
|
||||
|
||||
for object in objects {
|
||||
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
||||
|
||||
if seenIds.contains(objectId) {
|
||||
objectsToDelete.append(object)
|
||||
} else {
|
||||
seenIds.insert(objectId)
|
||||
}
|
||||
}
|
||||
|
||||
if !objectsToDelete.isEmpty {
|
||||
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||
for object in objectsToDelete {
|
||||
context.delete(object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Context
|
||||
|
||||
func save() {
|
||||
@@ -232,12 +281,10 @@ class CoreDataStack: ObservableObject {
|
||||
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
|
||||
/// This is necessary for the widget to see changes, as it opens the database read-only.
|
||||
private func checkpointWAL() {
|
||||
if viewContext.hasChanges {
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
let coordinator = persistentContainer.persistentStoreCoordinator
|
||||
coordinator.performAndWait {
|
||||
viewContext.performAndWait {
|
||||
if viewContext.hasChanges {
|
||||
try? viewContext.save()
|
||||
}
|
||||
viewContext.refreshAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,3 +201,49 @@ struct PortfolioSummary {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Portfolio Forecast
|
||||
|
||||
struct PortfolioForecast {
|
||||
let currentValue: Decimal
|
||||
let forecastValue: Decimal
|
||||
let forecastDate: Date
|
||||
let confidenceLower: Decimal
|
||||
let confidenceUpper: Decimal
|
||||
let monthlyGrowthRate: Decimal
|
||||
let annualizedGrowthRate: Decimal
|
||||
|
||||
var formattedForecastValue: String {
|
||||
CurrencyFormatter.format(forecastValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedCurrentValue: String {
|
||||
CurrencyFormatter.format(currentValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
var formattedConfidenceRange: String {
|
||||
let lower = CurrencyFormatter.format(confidenceLower, style: .currency, maximumFractionDigits: 0)
|
||||
let upper = CurrencyFormatter.format(confidenceUpper, style: .currency, maximumFractionDigits: 0)
|
||||
return "\(lower) – \(upper)"
|
||||
}
|
||||
|
||||
var formattedForecastDate: String {
|
||||
forecastDate.monthYearString
|
||||
}
|
||||
|
||||
var formattedGrowthPercentage: String {
|
||||
guard currentValue > 0 else { return "+0%" }
|
||||
let growthPct = ((forecastValue - currentValue) / currentValue) * 100
|
||||
let prefix = growthPct >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.1f%%", NSDecimalNumber(decimal: growthPct).doubleValue)
|
||||
}
|
||||
|
||||
var formattedAnnualizedGrowthRate: String {
|
||||
let prefix = annualizedGrowthRate >= 0 ? "+" : ""
|
||||
return String(format: "\(prefix)%.1f%%", NSDecimalNumber(decimal: annualizedGrowthRate * 100).doubleValue)
|
||||
}
|
||||
|
||||
var isPositiveGrowth: Bool {
|
||||
forecastValue >= currentValue
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user