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:
alexandrev-tibco
2026-07-24 09:36:50 +02:00
parent 8d7cfc098b
commit 84d9240ee6
17 changed files with 1096 additions and 13 deletions
@@ -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(