Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)

Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el
swipe entre charts (el brush vivía dentro de chartContent, bajo el
simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de
periodo, fuera del área con gesto.

Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual
por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las
gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow,
comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries
(agregación mensual extraída de calculateEvolutionData como monthlySeries).
Presets limpian el rango custom; selección completa = sin filtro. El pinch
por-chart se mantiene como zoom visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
This commit is contained in:
alexandrev-tibco
2026-08-06 20:33:58 +02:00
parent d941ecf27b
commit f1aeafccf6
5 changed files with 225 additions and 185 deletions
@@ -94,6 +94,12 @@ class ChartsViewModel: ObservableObject {
@Published var selectedSource: InvestmentSource?
@Published var selectedSourceIds: Set<UUID> = []
@Published var selectedTimeRange: TimeRange = .year
/// Global custom data window (range brush): when set, it REPLACES the
/// preset time range as the data filter for EVERY chart. Month-granular.
@Published var customRange: ClosedRange<Date>?
/// Full-history monthly totals for the current source/account universe
/// the sparkline behind the range brush. Rebuilt with the snapshot cache.
@Published var fullHistorySeries: [(date: Date, value: Decimal)] = []
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var selectedBreakdown: BreakdownMode = .category
@@ -328,7 +334,9 @@ class ChartsViewModel: ObservableObject {
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
}
var filtered = snapshots
if let cutoff = self.selectedTimeRange.startDate() {
if let custom = self.customRange {
filtered = snapshots.filter { custom.contains($0.date) }
} else if let cutoff = self.selectedTimeRange.startDate() {
filtered = snapshots.filter { $0.date >= cutoff }
}
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
@@ -354,6 +362,28 @@ class ChartsViewModel: ObservableObject {
}
.store(in: &cancellables)
// Global custom range (brush): refilter the selected chart's data.
// Calls updateChartData directly (like performancePeriodMonths) because
// the main pipeline's skip-check doesn't track customRange.
$customRange
.dropFirst()
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
self.updateChartData(chartType: self.selectedChartType, category: self.selectedCategory, timeRange: self.selectedTimeRange)
}
.store(in: &cancellables)
// Picking a preset range clears the custom brush window.
$selectedTimeRange
.removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
guard let self, self.customRange != nil else { return }
self.customRange = nil
}
.store(in: &cancellables)
// Period comparison: react to date picker changes
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
@@ -489,9 +519,13 @@ class ChartsViewModel: ObservableObject {
hiddenHistoryMonths = freemiumValidator.hiddenHistoryMonths(in: snapshots)
snapshots = freemiumValidator.filterSnapshots(snapshots)
cachedSnapshots = snapshots
fullHistorySeries = monthlySeries(from: snapshots)
}
if let cutoffDate = timeRange.startDate() {
// The brush's custom window replaces the preset range as data filter.
if let custom = customRange {
snapshots = snapshots.filter { custom.contains($0.date) }
} else if let cutoffDate = timeRange.startDate() {
snapshots = snapshots.filter { $0.date >= cutoffDate }
}
@@ -566,7 +600,9 @@ class ChartsViewModel: ObservableObject {
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
}
var filteredForComparison = allSnapshots
if let cutoff = timeRange.startDate() {
if let custom = customRange {
filteredForComparison = allSnapshots.filter { custom.contains($0.date) }
} else if let cutoff = timeRange.startDate() {
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
}
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
@@ -736,13 +772,28 @@ class ChartsViewModel: ObservableObject {
// MARK: - Chart Calculations
/// Sets the custom window from two month-granular dates (inclusive months).
func setCustomRange(fromMonth: Date, toMonth: Date) {
let cal = Calendar.current
let start = cal.date(from: cal.dateComponents([.year, .month], from: fromMonth)) ?? fromMonth
let endMonthStart = cal.date(from: cal.dateComponents([.year, .month], from: toMonth)) ?? toMonth
let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: endMonthStart) ?? toMonth
customRange = start...max(start, end)
}
private func calculateEvolutionData(from snapshots: [Snapshot]) {
evolutionData = downsampleSeries(monthlySeries(from: snapshots), maxPoints: maxChartPoints)
}
/// Pure monthly aggregation (grid + gap fill + per-month totals) shared by
/// the evolution chart and the range-brush sparkline.
private func monthlySeries(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
// Ordered month grid + each month's index.
let sortedMonthKeys = Set(snapshots.map { chartMonth(for: $0.date) }).sorted {
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
}
let monthCount = sortedMonthKeys.count
guard monthCount > 0 else { evolutionData = []; return }
guard monthCount > 0 else { return [] }
var indexByKey: [DateComponents: Int] = [:]
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
@@ -779,7 +830,7 @@ class ChartsViewModel: ObservableObject {
series.append((date: date, value: monthTotals[i]))
}
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
return series
}
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {