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