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)
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user