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