1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por source (meses sin datos entre dos snapshots). Banner desechable en Dashboard + DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan datos reales. Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca: valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard. Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks de existencia que applyImport. ImportDataView muestra la confirmación antes de importar. El path real de import (applyImport/importDataAsync) NO se toca. Localización en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -31,6 +31,25 @@ class ImportService {
|
||||
case json
|
||||
}
|
||||
|
||||
/// Read-only summary of what an import WOULD do, computed against existing
|
||||
/// data without mutating anything. Presented to the user for confirmation
|
||||
/// before the real import runs.
|
||||
struct ImportPreview {
|
||||
var sourcesToCreate: Int = 0
|
||||
var snapshotsToCreate: Int = 0
|
||||
var snapshotsToUpdate: Int = 0
|
||||
var categoriesToCreate: Int = 0
|
||||
var goalsToCreate: Int = 0
|
||||
var journalToCreate: Int = 0
|
||||
var journalToUpdate: Int = 0
|
||||
|
||||
var hasAnything: Bool {
|
||||
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
|
||||
categoriesToCreate > 0 || goalsToCreate > 0 ||
|
||||
journalToCreate > 0 || journalToUpdate > 0
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportedAccount {
|
||||
let name: String
|
||||
let currency: String?
|
||||
@@ -207,6 +226,203 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Dry-Run Preview (read-only)
|
||||
|
||||
/// Parses the content and counts what an import WOULD do against existing
|
||||
/// data, WITHOUT mutating anything. Uses the same parse functions and the
|
||||
/// same existence checks as `applyImport`, but only reads.
|
||||
func previewImportAsync(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
var goals: [ImportedGoal] = []
|
||||
var journal: [ImportedJournalEntry] = []
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
case .json:
|
||||
parsed = self.parseJSON(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
let preview = self.computePreview(
|
||||
accounts: parsed,
|
||||
goals: goals,
|
||||
journal: journal,
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run for the CSV column-mapping flow.
|
||||
func previewCSVWithMappingAsync(
|
||||
content: String,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let preview = self.previewCSV(content)
|
||||
let accounts = self.parseCSVWithMapping(
|
||||
preview: preview,
|
||||
mapping: mapping,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
let result = self.computePreview(
|
||||
accounts: accounts,
|
||||
goals: [],
|
||||
journal: [],
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
|
||||
/// (same source name+account, same-day snapshot, category name, goal
|
||||
/// name+account, journal monthKey) but never writes. Because it never
|
||||
/// saves, it may not perfectly disambiguate multiple new sources/categories
|
||||
/// that collapse to the same name within one import; it errs toward the
|
||||
/// user-facing counts `applyImport` would produce for typical files.
|
||||
private func computePreview(
|
||||
accounts: [ImportedAccount],
|
||||
goals: [ImportedGoal],
|
||||
journal: [ImportedJournalEntry],
|
||||
context: NSManagedObjectContext
|
||||
) -> ImportPreview {
|
||||
var preview = ImportPreview()
|
||||
|
||||
let existingCategories = fetchCategories(in: context)
|
||||
let categoryLookup = buildCategoryLookup(from: existingCategories)
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
// Track names we would create so duplicates within the same import file
|
||||
// aren't double-counted.
|
||||
var plannedCategoryKeys = Set<String>()
|
||||
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
|
||||
var accountsByName: [String: Account] = existingAccountsLookup
|
||||
|
||||
// "Other" is created on demand by applyImport when a source has no
|
||||
// resolvable category; only count it if we actually need it.
|
||||
var otherCategoryNeeded = false
|
||||
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
|
||||
|
||||
for importedAccount in accounts {
|
||||
let account = existingAccountsLookup[importedAccount.name]
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
named: importedCategory.name,
|
||||
lookup: categoryLookup
|
||||
)
|
||||
let shouldUseOther = existingCategory == nil &&
|
||||
importedCategory.colorHex == nil &&
|
||||
importedCategory.icon == nil
|
||||
|
||||
if existingCategory == nil {
|
||||
if shouldUseOther {
|
||||
otherCategoryNeeded = otherCategoryNeeded || !otherExists
|
||||
} else {
|
||||
let key = normalizedCategoryName(
|
||||
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
||||
)
|
||||
if !plannedCategoryKeys.contains(key) {
|
||||
plannedCategoryKeys.insert(key)
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account?.safeId
|
||||
let existingSource = fetchSource(
|
||||
named: importedSource.name,
|
||||
accountId: accountId,
|
||||
in: context
|
||||
)
|
||||
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
|
||||
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
|
||||
plannedSourceKeys.insert(sourceKey)
|
||||
preview.sourcesToCreate += 1
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
// Only an EXISTING source can have a same-day snapshot to update.
|
||||
if let existing = existingSource,
|
||||
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
|
||||
preview.snapshotsToUpdate += 1
|
||||
} else {
|
||||
preview.snapshotsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if otherCategoryNeeded {
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
|
||||
// Goals — dedup by name within account (case-insensitive), same as applyImport.
|
||||
var plannedGoalKeys = Set<String>()
|
||||
for importedGoal in goals {
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
|
||||
if !plannedGoalKeys.contains(key) {
|
||||
plannedGoalKeys.insert(key)
|
||||
preview.goalsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Journal — dedup by monthKey, same as applyImport.
|
||||
var plannedJournalKeys = Set<String>()
|
||||
for importedEntry in journal {
|
||||
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
|
||||
plannedJournalKeys.insert(importedEntry.monthKey)
|
||||
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
|
||||
preview.journalToUpdate += 1
|
||||
} else {
|
||||
preview.journalToCreate += 1
|
||||
}
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
/// Read-only fetch of an existing source by name within an account (or with
|
||||
/// no account). Mirrors applyImport's `name && account?.id` match.
|
||||
private func fetchSource(
|
||||
named name: String,
|
||||
accountId: UUID?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> InvestmentSource? {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "name == %@", name)
|
||||
let matches = (try? context.fetch(request)) ?? []
|
||||
return matches.first { $0.account?.id == accountId }
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseCSV(
|
||||
|
||||
Reference in New Issue
Block a user