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