Primera build enviada
This commit is contained in:
@@ -19,8 +19,14 @@ class AccountStore: ObservableObject {
|
||||
self.accountRepository = accountRepository ?? AccountRepository()
|
||||
self.iapService = iapService
|
||||
|
||||
self.accountRepository.fetchAccounts()
|
||||
// Clean up any duplicate Default accounts from previous bug
|
||||
self.accountRepository.cleanupDuplicateDefaultAccounts()
|
||||
|
||||
// Create default account if needed (now fetches directly from DB)
|
||||
let defaultAccount = self.accountRepository.createDefaultAccountIfNeeded()
|
||||
|
||||
// Trigger async fetch to populate the published accounts array
|
||||
self.accountRepository.fetchAccounts()
|
||||
accounts = self.accountRepository.accounts
|
||||
selectedAccount = defaultAccount
|
||||
|
||||
@@ -33,9 +39,23 @@ class AccountStore: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] accounts in
|
||||
self?.accounts = accounts
|
||||
if let selected = self?.selectedAccount, selected.isDeleted {
|
||||
self?.selectedAccount = nil
|
||||
}
|
||||
self?.syncSelectedAccount()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .didResetData)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.accountRepository.fetchAccounts()
|
||||
self.selectedAccount = nil
|
||||
self.showAllAccounts = true
|
||||
self.persistSelection()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func loadSelection() {
|
||||
@@ -53,8 +73,8 @@ class AccountStore: ObservableObject {
|
||||
|
||||
private func syncSelectedAccount() {
|
||||
if showAllAccounts { return }
|
||||
if let selected = selectedAccount,
|
||||
accounts.contains(where: { $0.id == selected.id }) {
|
||||
if let selectedId = selectedAccount?.safeId,
|
||||
accounts.contains(where: { $0.safeId == selectedId }) {
|
||||
return
|
||||
}
|
||||
selectedAccount = accounts.first
|
||||
@@ -62,6 +82,7 @@ class AccountStore: ObservableObject {
|
||||
|
||||
func selectAllAccounts() {
|
||||
showAllAccounts = true
|
||||
selectedAccount = nil
|
||||
persistSelection()
|
||||
}
|
||||
|
||||
@@ -75,7 +96,7 @@ class AccountStore: ObservableObject {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.showAllAccounts = showAllAccounts
|
||||
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.id
|
||||
settings.selectedAccountId = showAllAccounts ? nil : selectedAccount?.safeId
|
||||
CoreDataStack.shared.save()
|
||||
}
|
||||
|
||||
@@ -83,3 +104,10 @@ class AccountStore: ObservableObject {
|
||||
iapService.isPremium || accounts.count < 1
|
||||
}
|
||||
}
|
||||
|
||||
extension Account {
|
||||
var safeId: UUID? {
|
||||
guard !isDeleted else { return nil }
|
||||
return value(forKey: "id") as? UUID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,15 @@ class CalculationService {
|
||||
let monthChange = calculatePeriodChange(sources: sources, from: monthAgo)
|
||||
let yearChange = calculatePeriodChange(sources: sources, from: yearAgo)
|
||||
|
||||
let allTimeReturn = totalValue - totalContributions
|
||||
let allTimeReturnPercentage = totalContributions > 0
|
||||
? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100
|
||||
let baselineTotal = sources.reduce(Decimal.zero) { partial, source in
|
||||
if let firstSnapshot = source.sortedSnapshotsByDateAscending.first {
|
||||
return partial + firstSnapshot.decimalValue
|
||||
}
|
||||
return partial + source.latestValue
|
||||
}
|
||||
let allTimeReturn = totalValue - baselineTotal
|
||||
let allTimeReturnPercentage = baselineTotal > 0
|
||||
? NSDecimalNumber(decimal: allTimeReturn / baselineTotal).doubleValue * 100
|
||||
: 0
|
||||
|
||||
let lastUpdated = snapshots.map { $0.date }.max()
|
||||
@@ -458,57 +464,36 @@ class CalculationService {
|
||||
|
||||
private func buildCategorySeries(from snapshots: [Snapshot]) -> [SeriesPoint] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
|
||||
.sorted()
|
||||
guard !uniqueDates.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
|
||||
var contributionsByDate: [Date: Decimal] = [:]
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(
|
||||
(date: snapshot.date, value: snapshot.decimalValue)
|
||||
)
|
||||
let day = Calendar.current.startOfDay(for: snapshot.date)
|
||||
contributionsByDate[day, default: 0] += snapshot.decimalContribution
|
||||
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 indices: [UUID: Int] = [:]
|
||||
var series: [SeriesPoint] = []
|
||||
series.reserveCapacity(groupedByMonth.count)
|
||||
|
||||
for (index, date) in uniqueDates.enumerated() {
|
||||
let nextDate = index + 1 < uniqueDates.count
|
||||
? uniqueDates[index + 1]
|
||||
: Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
var contributions: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: (date: Date, value: Decimal)?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
|
||||
if let latest {
|
||||
total += latest.value
|
||||
for snapshot in monthSnapshots {
|
||||
contributions += snapshot.decimalContribution
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
series.append(
|
||||
SeriesPoint(
|
||||
date: date,
|
||||
value: total,
|
||||
contribution: contributionsByDate[date] ?? 0
|
||||
)
|
||||
)
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
series.append(SeriesPoint(date: date, value: total, contribution: contributions))
|
||||
}
|
||||
|
||||
return series
|
||||
return series.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private func calculateMonthlyReturns(from series: [SeriesPoint]) -> [InvestmentMetrics.MonthlyReturn] {
|
||||
|
||||
@@ -11,6 +11,7 @@ class ImportService {
|
||||
let accountsCreated: Int
|
||||
let sourcesCreated: Int
|
||||
let snapshotsCreated: Int
|
||||
let snapshotsUpdated: Int
|
||||
let errors: [String]
|
||||
}
|
||||
|
||||
@@ -385,11 +386,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
var accountsCreated = 0
|
||||
var sourcesCreated = 0
|
||||
var snapshotsCreated = 0
|
||||
var snapshotsUpdated = 0
|
||||
var errors: [String] = []
|
||||
|
||||
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
|
||||
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
|
||||
categoryRepository.createCategory(
|
||||
let existingCategories = fetchCategories(in: context)
|
||||
var categoryLookup = buildCategoryLookup(from: existingCategories)
|
||||
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup)
|
||||
?? categoryRepository.createCategory(
|
||||
name: "Other",
|
||||
colorHex: "#64748B",
|
||||
icon: "ellipsis.circle.fill"
|
||||
@@ -398,17 +401,22 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
|
||||
var completionDatesByMonth: [String: Date] = [:]
|
||||
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
|
||||
let account = existingAccount ?? accountRepository.createAccount(
|
||||
name: importedAccount.name,
|
||||
currency: importedAccount.currency,
|
||||
inputMode: importedAccount.inputMode,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
// Build lookup of existing accounts by fetching directly from database
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
if existingAccount == nil {
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = existingAccountsLookup[importedAccount.name]
|
||||
let account: Account
|
||||
if let existing = existingAccount {
|
||||
account = existing
|
||||
} else {
|
||||
account = accountRepository.createAccount(
|
||||
name: importedAccount.name,
|
||||
currency: importedAccount.currency,
|
||||
inputMode: importedAccount.inputMode,
|
||||
notificationFrequency: importedAccount.notificationFrequency,
|
||||
customFrequencyMonths: importedAccount.customFrequencyMonths
|
||||
)
|
||||
accountsCreated += 1
|
||||
}
|
||||
|
||||
@@ -431,8 +439,9 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account.safeId
|
||||
let existingSource = sourceRepository.sources.first(where: {
|
||||
$0.name == importedSource.name && $0.account?.id == account.id
|
||||
$0.name == importedSource.name && $0.account?.id == accountId
|
||||
})
|
||||
let source = existingSource ?? sourceRepository.createSource(
|
||||
name: importedSource.name,
|
||||
@@ -446,14 +455,30 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
// Check if a snapshot with the same date already exists for this source
|
||||
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
|
||||
|
||||
if let existing = existingSnapshot {
|
||||
// Update existing snapshot instead of creating a duplicate
|
||||
existing.value = NSDecimalNumber(decimal: snapshot.value)
|
||||
if let contribution = snapshot.contribution {
|
||||
existing.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
existing.notes = notes
|
||||
}
|
||||
snapshotsUpdated += 1
|
||||
} else {
|
||||
// Create new snapshot
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
}
|
||||
snapshotProgress?(snapshotsCreated)
|
||||
|
||||
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
||||
@@ -491,6 +516,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
accountsCreated: accountsCreated,
|
||||
sourcesCreated: sourcesCreated,
|
||||
snapshotsCreated: snapshotsCreated,
|
||||
snapshotsUpdated: snapshotsUpdated,
|
||||
errors: errors
|
||||
)
|
||||
}
|
||||
@@ -516,6 +542,50 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return lookup
|
||||
}
|
||||
|
||||
private func fetchCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||
]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private func fetchAccountsLookup(in context: NSManagedObjectContext) -> [String: Account] {
|
||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)]
|
||||
let accounts = (try? context.fetch(request)) ?? []
|
||||
var lookup: [String: Account] = [:]
|
||||
for account in accounts {
|
||||
lookup[account.name] = account
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
/// Fetch a snapshot for a given source and date (same day)
|
||||
private func fetchSnapshot(
|
||||
for source: InvestmentSource,
|
||||
date: Date,
|
||||
in context: NSManagedObjectContext
|
||||
) -> Snapshot? {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
|
||||
// Match snapshots on the same day (ignore time component)
|
||||
let calendar = Calendar.current
|
||||
let startOfDay = calendar.startOfDay(for: date)
|
||||
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||
|
||||
request.predicate = NSPredicate(
|
||||
format: "source == %@ AND date >= %@ AND date < %@",
|
||||
source,
|
||||
startOfDay as NSDate,
|
||||
endOfDay as NSDate
|
||||
)
|
||||
request.fetchLimit = 1
|
||||
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||
let normalized = normalizedCategoryName(rawName)
|
||||
for mapping in categoryAliasMappings {
|
||||
|
||||
@@ -173,6 +173,7 @@ extension NotificationService {
|
||||
|
||||
extension Notification.Name {
|
||||
static let openSourceDetail = Notification.Name("openSourceDetail")
|
||||
static let didResetData = Notification.Name("didResetData")
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class SampleDataService {
|
||||
static let shared = SampleDataService()
|
||||
@@ -20,10 +21,32 @@ class SampleDataService {
|
||||
let goalRepository = GoalRepository(context: context)
|
||||
let transactionRepository = TransactionRepository(context: context)
|
||||
|
||||
let categories = categoryRepository.categories
|
||||
let stocksCategory = categories.first { $0.name == "Stocks" } ?? categories.first!
|
||||
let cryptoCategory = categories.first { $0.name == "Crypto" } ?? categories.first!
|
||||
let realEstateCategory = categories.first { $0.name == "Real Estate" } ?? categories.first!
|
||||
let categories = fetchCategories(in: context)
|
||||
guard let fallbackCategory = categories.first else { return }
|
||||
let stocksCategory = resolveCategory(
|
||||
named: "Stocks",
|
||||
fallback: fallbackCategory,
|
||||
colorHex: "#3B82F6",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
repository: categoryRepository,
|
||||
context: context
|
||||
)
|
||||
let cryptoCategory = resolveCategory(
|
||||
named: "Crypto",
|
||||
fallback: fallbackCategory,
|
||||
colorHex: "#10B981",
|
||||
icon: "bitcoinsign.circle.fill",
|
||||
repository: categoryRepository,
|
||||
context: context
|
||||
)
|
||||
let realEstateCategory = resolveCategory(
|
||||
named: "Real Estate",
|
||||
fallback: fallbackCategory,
|
||||
colorHex: "#F59E0B",
|
||||
icon: "house.fill",
|
||||
repository: categoryRepository,
|
||||
context: context
|
||||
)
|
||||
|
||||
let stocks = sourceRepository.createSource(
|
||||
name: "Index Fund",
|
||||
@@ -98,4 +121,28 @@ class SampleDataService {
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||
]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private func resolveCategory(
|
||||
named name: String,
|
||||
fallback: Category,
|
||||
colorHex: String,
|
||||
icon: String,
|
||||
repository: CategoryRepository,
|
||||
context: NSManagedObjectContext
|
||||
) -> Category {
|
||||
if let existing = fetchCategories(in: context)
|
||||
.first(where: { $0.name == name }) {
|
||||
return existing
|
||||
}
|
||||
return repository.createCategory(name: name, colorHex: colorHex, icon: icon)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user