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
@@ -17,6 +17,7 @@ class ChartsViewModel: ObservableObject {
case drawdown = "Drawdown"
case volatility = "Volatility"
case prediction = "Prediction"
case yearOverYear = "Year vs Year"
var id: String { rawValue }
@@ -32,6 +33,7 @@ class ChartsViewModel: ObservableObject {
case .drawdown: return "arrow.down.right.circle"
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
case .yearOverYear: return "calendar"
}
}
@@ -40,7 +42,7 @@ class ChartsViewModel: ObservableObject {
case .evolution:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction:
.drawdown, .volatility, .prediction, .yearOverYear:
return true
}
}
@@ -67,6 +69,8 @@ class ChartsViewModel: ObservableObject {
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
case .yearOverYear:
return "Compare portfolio values across years"
}
}
}
@@ -104,6 +108,13 @@ class ChartsViewModel: ObservableObject {
@Published var volatilityData: [(date: Date, volatility: Double)] = []
@Published var predictionData: [Prediction] = []
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
@Published var yearOverYearData: [YearSeries] = []
struct YearSeries: Identifiable {
let id: Int // year
let year: Int
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
}
@Published var isLoading = false
@Published var showingPaywall = false
@@ -280,6 +291,8 @@ class ChartsViewModel: ObservableObject {
switch chartType {
case .evolution, .performance:
return [.all, .yearToDate, .year, .quarter]
case .yearOverYear:
return [] // No time range filter for YoY always uses all data
default:
return [.month, .quarter, .halfYear, .year, .all]
}
@@ -368,6 +381,8 @@ class ChartsViewModel: ObservableObject {
calculateVolatilityData(from: completedSnapshots)
case .prediction:
calculatePredictionData(from: completedSnapshots)
case .yearOverYear:
calculateYearOverYearData(from: completedSnapshots)
}
if let selected = selectedCategory,
@@ -955,6 +970,57 @@ class ChartsViewModel: ObservableObject {
predictionData = result.predictions
}
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
guard !snapshots.isEmpty else {
yearOverYearData = []
return
}
// Build all monthly totals (no time range filter for YoY)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
}
let calendar = Calendar.current
// group by (year, month), take latest per source
var byYearMonth: [Int: [Int: Decimal]] = [:]
var latestBySource: [UUID: Snapshot] = [:]
let sorted = allSnapshots.sorted { $0.date < $1.date }
for snap in sorted {
guard let sourceId = snap.source?.id else { continue }
latestBySource[sourceId] = snap
let year = calendar.component(.year, from: snap.date)
let month = calendar.component(.month, from: snap.date) // 1-based
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
byYearMonth[year, default: [:]][month] = total
}
let years = byYearMonth.keys.sorted()
guard !years.isEmpty else {
yearOverYearData = []
return
}
// Forward-fill within each year: carry previous month if no data
yearOverYearData = years.map { year in
var values = [Decimal](repeating: .zero, count: 12)
var last: Decimal = .zero
for monthIdx in 0..<12 {
let month = monthIdx + 1
if let v = byYearMonth[year]?[month] {
last = v
}
values[monthIdx] = last
}
return YearSeries(id: year, year: year, values: values)
}
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)