Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento

Charts iPad (rediseño):
- Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max
  Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre
  los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a
  calculateMetrics producía KPIs absurdos)
- Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección:
  Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad
- Selector de periodo como Picker segmentado nativo en la cabecera del chart
  (fuera del sidebar); título + descripción del chart visibles
- Eliminado sheet de paywall duplicado del layout iPad

Zoom (todas las series temporales):
- Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el
  único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom
- Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility

Focus Mode eliminado (todo siempre disponible):
- Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage
- Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList
- Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in
- PeriodReturnsCard siempre visible

Rendimiento y bugs (de la auditoría):
- El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown
- calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación
  lineal sobreestimaba con historiales cortos)
- Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación
  real de las asignaciones simuladas del usuario
- UITest de captura de Charts con manejo del alert de notificaciones

12 strings nuevas localizadas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-07-03 10:17:10 +02:00
parent d0a51d0162
commit db844d5e80
19 changed files with 557 additions and 230 deletions
@@ -87,16 +87,6 @@ class ChartsViewModel: ObservableObject {
}
}
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
let types: [ChartType] = calmModeEnabled
? [.evolution, .allocation, .performance, .contributions, .comparison, .periodComparison]
: ChartType.allCases
if !types.contains(selectedChartType) {
selectedChartType = .evolution
}
return types
}
// MARK: - Published Properties
@Published var selectedChartType: ChartType = .evolution
@@ -124,6 +114,8 @@ class ChartsViewModel: ObservableObject {
/// Months of data hidden by the free-tier 12-month history limit (0 for premium).
/// Drives the "unlock X more months" teaser in the charts screen.
@Published var hiddenHistoryMonths: Int = 0
/// Portfolio-level metrics for the active time range feeds the iPad KPI header.
@Published var portfolioMetrics: InvestmentMetrics = .empty
@Published var yoySelectedYears: Set<Int> = []
struct YearSeries: Identifiable {
@@ -311,6 +303,13 @@ class ChartsViewModel: ObservableObject {
self.lastBreakdown != selectedBreakdown
if hasChanges {
// Raw snapshots only depend on the data selection (sources/account/
// category) switching chart type, range or breakdown reuses them.
let dataSelectionChanged = self.lastCategoryId != category?.id ||
self.lastSourceId != selectedSource?.id ||
self.lastSourceIds != sourceIds ||
self.lastAccountId != safeSelectedAccountId ||
self.lastShowAllAccounts != showAll
self.lastChartType = chartType
self.lastTimeRange = timeRange
self.lastCategoryId = category?.id
@@ -319,7 +318,9 @@ class ChartsViewModel: ObservableObject {
self.lastAccountId = safeSelectedAccountId
self.lastShowAllAccounts = showAll
self.lastBreakdown = selectedBreakdown
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
if dataSelectionChanged {
self.cachedSnapshots = nil
}
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
}
}
@@ -468,6 +469,9 @@ class ChartsViewModel: ObservableObject {
snapshots = snapshots.filter { $0.date >= cutoffDate }
}
// Portfolio KPIs for the visible range (iPad header)
portfolioMetrics = computePortfolioKPIs(from: snapshots)
let completedSnapshots = filterSnapshotsForCharts(
sources: sources,
snapshots: snapshots
@@ -1072,7 +1076,11 @@ class ChartsViewModel: ObservableObject {
return
}
var peak = totals.first!.totalValue
guard let firstTotal = totals.first else {
drawdownData = []
return
}
var peak = firstTotal.totalValue
var data: [(date: Date, drawdown: Double)] = []
for point in totals {
@@ -1378,12 +1386,16 @@ class ChartsViewModel: ObservableObject {
))
}
// Only update if sources changed (avoid resetting sliders on normal refresh)
// Only reset sliders when the source set actually changed; otherwise refresh
// current percentages but keep the user's simulated allocations.
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
simulatorSources = simulatorNew
} else {
// Update currentPct but preserve simulatedPct
simulatorSources = simulatorNew
simulatorSources = zip(simulatorSources, simulatorNew).map { old, new in
var updated = new
updated.simulatedPct = old.simulatedPct
return updated
}
}
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
@@ -1540,6 +1552,65 @@ class ChartsViewModel: ObservableObject {
periodComparisonData = result
}
/// Portfolio-level KPIs computed over the AGGREGATED monthly totals.
/// Feeding raw multi-source snapshots into CalculationService.calculateMetrics
/// produced nonsense (first/last belong to different sources) the aggregate
/// series is the correct basis for portfolio return, CAGR, volatility and drawdown.
private func computePortfolioKPIs(from snapshots: [Snapshot]) -> InvestmentMetrics {
let totals = monthlyTotals(from: snapshots)
guard totals.count >= 2,
let first = totals.first, let last = totals.last,
first.totalValue > 0 else {
let value = totals.last?.totalValue ?? 0
return InvestmentMetrics(
totalValue: value, totalContributions: 0,
absoluteReturn: 0, percentageReturn: 0,
cagr: 0, twr: 0, volatility: 0, maxDrawdown: 0, sharpeRatio: 0,
bestMonth: nil, worstMonth: nil, winRate: 0, averageMonthlyReturn: 0,
startDate: totals.first?.date, endDate: totals.last?.date,
totalMonths: totals.count
)
}
let absolute = last.totalValue - first.totalValue
let percentage = (absolute / first.totalValue) * 100
let cagr = calculationService.calculateCAGR(
startValue: first.totalValue, endValue: last.totalValue,
startDate: first.date, endDate: last.date
)
// Monthly returns over the aggregate series
var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
for i in 1..<totals.count where totals[i - 1].totalValue > 0 {
let r = NSDecimalNumber(
decimal: (totals[i].totalValue - totals[i - 1].totalValue) / totals[i - 1].totalValue
).doubleValue * 100
monthlyReturns.append(.init(date: totals[i].date, returnPercentage: r))
}
let volatility = calculationService.calculateVolatility(monthlyReturns: monthlyReturns)
let maxDrawdown = calculationService.calculateMaxDrawdown(values: totals.map { $0.totalValue })
let avgMonthly = monthlyReturns.isEmpty
? 0 : monthlyReturns.map { $0.returnPercentage }.reduce(0, +) / Double(monthlyReturns.count)
let sharpe = calculationService.calculateSharpeRatio(
averageReturn: avgMonthly * 12, volatility: volatility, riskFreeRate: 2.0
)
return InvestmentMetrics(
totalValue: last.totalValue,
totalContributions: 0,
absoluteReturn: absolute,
percentageReturn: percentage,
cagr: cagr, twr: 0,
volatility: volatility, maxDrawdown: maxDrawdown, sharpeRatio: sharpe,
bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }),
worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }),
winRate: calculationService.calculateWinRate(monthlyReturns: monthlyReturns),
averageMonthlyReturn: avgMonthly,
startDate: first.date, endDate: last.date,
totalMonths: totals.count
)
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)