Release 1.4.0 (build 31): retención, quick update, streak, what's new

- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes)
- 1B: Badge de racha mensual en Dashboard (streak counter)
- 1C: Empty states mejorados en Goals y Journal con CTAs claros
- 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla
- 2B: Notificación batch update redirige al Quick Update sheet
- 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update
- 3A: App Store version check banner en Settings
- 3C: What's New sheet en primer launch de versión nueva
- Localización completa en 7 idiomas para todas las nuevas strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-21 23:09:36 +02:00
parent 773da6800b
commit b48a47ce10
41 changed files with 2659 additions and 483 deletions
@@ -655,6 +655,184 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
]
}
// MARK: - CSV Column Mapping
/// Parsed preview data for the mapping UI
struct CSVPreview {
let rows: [[String]]
let columnCount: Int
func headers(hasHeaderRow: Bool) -> [String] {
if hasHeaderRow, let first = rows.first {
return first
}
return (0..<columnCount).map { "Column \($0 + 1)" }
}
func sampleRow(hasHeaderRow: Bool) -> [String]? {
let start = hasHeaderRow ? 1 : 0
return rows.count > start ? rows[start] : nil
}
}
func previewCSV(_ content: String) -> CSVPreview {
let rows = parseCSVRows(content)
let columnCount = rows.first?.count ?? 0
return CSVPreview(rows: rows, columnCount: columnCount)
}
// Maps each app field to a CSV column index or a constant value
struct CSVMappingConfig {
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
static let notMapped = -1
static let constant = -2
static let useToday = -3
var hasHeaderRow: Bool = true
var sourceIndex: Int = notMapped
var sourceConstant: String = ""
var valueIndex: Int = notMapped
var dateIndex: Int = useToday // default: use today
var categoryIndex: Int = constant
var categoryConstant: String = "Other" // default category name when constant
var contributionIndex: Int = notMapped
var notesIndex: Int = notMapped
var isValid: Bool {
let sourceOk: Bool = {
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
return sourceIndex >= 0
}()
let valueOk = valueIndex >= 0
return sourceOk && valueOk
}
}
func importCSVWithMapping(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> ImportResult {
let preview = previewCSV(content)
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
}
func importCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
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 totalSnapshots = accounts.reduce(0) { t, a in
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(accounts, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
private func parseCSVWithMapping(
preview: CSVPreview,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> [ImportedAccount] {
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
let fallbackAccount = defaultAccountName ?? "Personal"
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in dataRows {
// Source name
let sourceName: String
if mapping.sourceIndex == CSVMappingConfig.constant {
guard !mapping.sourceConstant.isEmpty else { continue }
sourceName = mapping.sourceConstant
} else if mapping.sourceIndex >= 0 {
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
sourceName = v
} else {
continue
}
// Value
guard mapping.valueIndex >= 0,
let valStr = row.safeValue(at: mapping.valueIndex),
let value = parseDecimal(valStr) else { continue }
// Date
let date: Date
if mapping.dateIndex == CSVMappingConfig.useToday {
date = Calendar.current.startOfDay(for: Date())
} else if mapping.dateIndex >= 0,
let dateStr = row.safeValue(at: mapping.dateIndex),
let parsed = parseDate(dateStr) {
date = parsed
} else {
date = Calendar.current.startOfDay(for: Date())
}
// Category
let categoryName: String
if mapping.categoryIndex == CSVMappingConfig.constant {
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
} else if mapping.categoryIndex >= 0,
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
categoryName = v
} else {
categoryName = "Other"
}
// Contribution
let contribution: Decimal? = mapping.contributionIndex >= 0
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
: nil
// Notes
let notes: String? = mapping.notesIndex >= 0
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
: nil
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {