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)
@@ -22,6 +22,7 @@ class DashboardViewModel: ObservableObject {
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var updateStreak: Int = 0
// MARK: - Portfolio Forecast
@@ -204,6 +205,9 @@ class DashboardViewModel: ObservableObject {
// Calculate portfolio forecast
updatePortfolioForecast()
// Compute update streak
updateStreak = computeStreak(from: evolutionData)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
@@ -535,6 +539,42 @@ class DashboardViewModel: ObservableObject {
return annualized
}
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
guard !evolutionData.isEmpty else { return 0 }
let calendar = Calendar.current
// Group into (year, month) set
let monthSet: Set<DateComponents> = Set(evolutionData.map { point in
let comps = calendar.dateComponents([.year, .month], from: point.date)
return DateComponents(year: comps.year, month: comps.month)
})
// Get sorted unique months descending
let sortedMonths = monthSet
.compactMap { comps -> Date? in calendar.date(from: comps) }
.sorted(by: >)
guard let mostRecent = sortedMonths.first else { return 0 }
var streak = 1
var current = mostRecent
for i in 1..<sortedMonths.count {
guard let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonths[i]
let prevComps = calendar.dateComponents([.year, .month], from: prev)
let expectedComps = calendar.dateComponents([.year, .month], from: expected)
if prevComps.year == expectedComps.year && prevComps.month == expectedComps.month {
streak += 1
current = prev
} else {
break
}
}
return streak
}
// Virtual snapshot for forecast calculations
private struct VirtualSnapshot {
let date: Date
@@ -161,6 +161,10 @@ class GoalsViewModel: ObservableObject {
goalRepository.deleteGoal(goal)
}
func archiveGoal(_ goal: Goal) {
goalRepository.updateGoal(goal, isActive: !goal.isActive)
}
func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
@@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject {
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategory: Category?
@Published var selectedCategoryIds: Set<UUID> = []
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@@ -55,7 +55,7 @@ class SourceListViewModel: ObservableObject {
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategory,
$selectedCategoryIds,
$selectedAccount
)
.combineLatest($showAllAccounts)
@@ -88,9 +88,12 @@ class SourceListViewModel: ObservableObject {
filtered = filtered.filter { $0.account?.id == selectedAccountId }
}
// Filter by category
if let category = selectedCategory {
filtered = filtered.filter { $0.category?.id == category.id }
// Filter by category (multi-select)
if !selectedCategoryIds.isEmpty {
filtered = filtered.filter {
guard let id = $0.category?.id else { return false }
return selectedCategoryIds.contains(id)
}
}
// Filter by search text
@@ -200,21 +203,29 @@ class SourceListViewModel: ObservableObject {
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
var isFiltered: Bool {
!searchText.isEmpty || selectedCategory != nil
!searchText.isEmpty || !selectedCategoryIds.isEmpty
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
selectedCategory = category
guard let category else {
selectedCategoryIds = []
return
}
if selectedCategoryIds.contains(category.id) {
selectedCategoryIds.remove(category.id)
} else {
selectedCategoryIds.insert(category.id)
}
}
func clearFilters() {
searchText = ""
selectedCategory = nil
selectedCategoryIds = []
}
}