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:
@@ -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]] {
|
||||
|
||||
Reference in New Issue
Block a user