Primera build enviada

This commit is contained in:
2026-01-19 14:40:43 +01:00
parent c6be398e5a
commit b03d35194f
36 changed files with 1641 additions and 561 deletions
+32 -4
View File
@@ -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] {
+92 -22
View File
@@ -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)
}
}