Add 3 interactive chart types: Compare, What If, Period vs Period (build 34)

- Compare (free): multi-source overlay chart, Base 100 / Return % / Value modes,
  chip selector, reactive to time range filter
- What If (premium): allocation simulator with sliders per source, dual-line chart
  actual vs simulated indexed to 100, orange warning when weights ≠ 100%
- Period vs Period (free): date-picker-driven comparison of any two custom periods,
  return % normalized from month 1 = 0%, color-coded legend with period labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-26 15:32:57 +02:00
parent 270edeb536
commit 12ab2d6009
6 changed files with 1125 additions and 12 deletions
@@ -18,6 +18,9 @@ class ChartsViewModel: ObservableObject {
case volatility = "Volatility"
case prediction = "Prediction"
case yearOverYear = "Year vs Year"
case comparison = "Compare"
case simulator = "What If"
case periodComparison = "Period vs Period"
var id: String { rawValue }
@@ -34,15 +37,18 @@ class ChartsViewModel: ObservableObject {
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
case .yearOverYear: return "calendar"
case .comparison: return "chart.xyaxis.line"
case .simulator: return "slider.horizontal.3"
case .periodComparison: return "calendar.badge.clock"
}
}
var isPremium: Bool {
switch self {
case .evolution:
case .evolution, .comparison, .periodComparison:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction, .yearOverYear:
.drawdown, .volatility, .prediction, .yearOverYear, .simulator:
return true
}
}
@@ -71,6 +77,12 @@ class ChartsViewModel: ObservableObject {
return "View 12-month forecasts"
case .yearOverYear:
return "Compare portfolio values across years"
case .comparison:
return "Compare sources side by side"
case .simulator:
return "Simulate portfolio reallocation"
case .periodComparison:
return "Compare two time periods side by side"
}
}
}
@@ -116,6 +128,56 @@ class ChartsViewModel: ObservableObject {
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
}
// MARK: - Comparison chart data models
enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
case indexed = "Base 100"
case returnPct = "Return %"
case absolute = "Value"
var id: String { rawValue }
}
struct ComparisonSeries: Identifiable {
let id: UUID
let name: String
let colorHex: String
let points: [(date: Date, value: Double)]
}
@Published var comparisonData: [ComparisonSeries] = []
@Published var comparisonSelectedSourceIds: Set<UUID> = []
@Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed
@Published var comparisonAvailableSources: [InvestmentSource] = []
// MARK: - Simulator chart data models
struct SimulatorSource: Identifiable {
let id: UUID
let name: String
let currentPct: Double
var simulatedPct: Double
let colorHex: String
}
@Published var simulatorSources: [SimulatorSource] = []
@Published var simulatorActualData: [(date: Date, value: Double)] = []
@Published var simulatorData: [(date: Date, value: Double)] = []
// MARK: - Period comparison data models
struct PeriodSeries: Identifiable {
let id: String
let label: String
let colorHex: String // e.g. "#0000FF" for blue, "#FF8000" for orange
let points: [(monthOffset: Int, returnPct: Double)]
}
@Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date()
@Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date()
@Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date()
@Published var periodComparisonData: [PeriodSeries] = []
@Published var isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
@@ -255,6 +317,51 @@ class ChartsViewModel: ObservableObject {
}
}
.store(in: &cancellables)
// Comparison chart: react to source selection and display mode changes
Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .comparison else { return }
self.isLoading = true
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
var snapshots: [Snapshot]
if let cached = self.cachedSnapshots {
snapshots = cached
} else {
snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
}
var filtered = snapshots
if let cutoff = self.selectedTimeRange.startDate() {
filtered = snapshots.filter { $0.date >= cutoff }
}
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
self.isLoading = false
}
.store(in: &cancellables)
// Simulator chart: react to slider changes
$simulatorSources
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
.sink { [weak self] sources in
guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return }
self.recalculateSimulatedLine(sources: sources)
}
.store(in: &cancellables)
// Period comparison: react to date picker changes
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .periodComparison else { return }
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
self.calculatePeriodComparisonData(allSnapshots: snapshots)
}
.store(in: &cancellables)
}
// MARK: - Data Loading
@@ -291,8 +398,10 @@ 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
case .yearOverYear, .simulator, .periodComparison:
return [] // No time range filter for these charts
case .comparison:
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
default:
return [.month, .quarter, .halfYear, .year, .all]
}
@@ -383,6 +492,35 @@ class ChartsViewModel: ObservableObject {
calculatePredictionData(from: completedSnapshots)
case .yearOverYear:
calculateYearOverYearData(from: completedSnapshots)
case .comparison:
// Use ALL sources, not filtered by selectedSource/category
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
comparisonAvailableSources = allSources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
let allSourceIds = allSources.compactMap { $0.id }
var allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
}
var filteredForComparison = allSnapshots
if let cutoff = timeRange.startDate() {
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
}
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
case .simulator:
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots)
case .periodComparison:
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
calculatePeriodComparisonData(allSnapshots: allSnapshots)
}
if let selected = selectedCategory,
@@ -406,6 +544,8 @@ class ChartsViewModel: ObservableObject {
switch chartType {
case .evolution, .prediction, .allocation, .performance:
return filtered
case .comparison, .simulator, .periodComparison:
return []
default:
return []
}
@@ -419,6 +559,8 @@ class ChartsViewModel: ObservableObject {
switch chartType {
case .evolution, .allocation, .performance:
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .comparison, .simulator, .periodComparison:
return []
default:
return []
}
@@ -1003,6 +1145,305 @@ class ChartsViewModel: ObservableObject {
}
}
// MARK: - Comparison Chart Calculation
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
let colorHexes = Self.sourceColorHexes
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
var result: [ComparisonSeries] = []
for (index, source) in sortedSources.enumerated() {
guard let sourceId = source.id as UUID?,
comparisonSelectedSourceIds.contains(sourceId) else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
// Group by month, take latest per month
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthlyValues: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthlyValues.sort { $0.date < $1.date }
guard !monthlyValues.isEmpty else { continue }
let firstValue = monthlyValues[0].value
let points: [(date: Date, value: Double)]
switch comparisonDisplayMode {
case .indexed:
points = monthlyValues.map { item in
let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0
return (date: item.date, value: v)
}
case .returnPct:
points = monthlyValues.map { item in
let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0
return (date: item.date, value: v)
}
case .absolute:
points = monthlyValues
}
let colorHex = colorHexes[index % colorHexes.count]
result.append(ComparisonSeries(
id: sourceId,
name: source.name,
colorHex: colorHex,
points: points
))
}
comparisonData = result
}
// MARK: - Simulator Chart Calculation
func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
guard !sources.isEmpty else {
simulatorSources = []
simulatorActualData = []
simulatorData = []
return
}
let colorHexes = Self.sourceColorHexes
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
// Build monthly totals per source
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
for source in sortedSources {
guard let sourceId = source.id as UUID? else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthly: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthly.sort { $0.date < $1.date }
sourceMonthlyValues[sourceId] = monthly
}
// Gather all unique months across sources
var allMonthsSet = Set<Date>()
for monthly in sourceMonthlyValues.values {
monthly.forEach { allMonthsSet.insert($0.date) }
}
let allMonths = allMonthsSet.sorted()
guard !allMonths.isEmpty else {
simulatorActualData = []
simulatorData = []
return
}
// Build actual total portfolio per month (forward-fill per source)
var lastValueBySource: [UUID: Double] = [:]
var actualTotals: [(date: Date, value: Double)] = []
for month in allMonths {
for source in sortedSources {
guard let sourceId = source.id as UUID? else { continue }
if let monthly = sourceMonthlyValues[sourceId],
let entry = monthly.first(where: { $0.date == month }) {
lastValueBySource[sourceId] = entry.value
}
}
let total = lastValueBySource.values.reduce(0, +)
actualTotals.append((date: month, value: total))
}
// Normalize actual to Base 100
let firstTotal = actualTotals.first?.value ?? 1
simulatorActualData = actualTotals.map { item in
(date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0)
}
// Compute current weights
let latestTotalValue = actualTotals.last?.value ?? 0
var simulatorNew: [SimulatorSource] = []
for (index, source) in sortedSources.enumerated() {
guard let sourceId = source.id as UUID? else { continue }
let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0
let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0
// Preserve existing simulatedPct if source already exists in simulatorSources
let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct
simulatorNew.append(SimulatorSource(
id: sourceId,
name: source.name,
currentPct: currentPct,
simulatedPct: existingPct,
colorHex: colorHexes[index % colorHexes.count]
))
}
// Only update if sources changed (avoid resetting sliders on normal refresh)
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
simulatorSources = simulatorNew
} else {
// Update currentPct but preserve simulatedPct
simulatorSources = simulatorNew
}
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
}
func recalculateSimulatedLine(sources: [SimulatorSource]) {
// Re-fetch source monthly values for recalculation from current allSources
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
let allSourceIds = allSources.compactMap { $0.id }
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
for source in allSources {
guard let sourceId = source.id as UUID? else { continue }
let sourceSnapshots = allSnapshots
.filter { $0.source?.id == sourceId }
.sorted { $0.date < $1.date }
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthly: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
let date = Calendar.current.date(from: key) ?? Date()
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
}
monthly.sort { $0.date < $1.date }
sourceMonthlyValues[sourceId] = monthly
}
var allMonthsSet = Set<Date>()
for monthly in sourceMonthlyValues.values {
monthly.forEach { allMonthsSet.insert($0.date) }
}
let allMonths = allMonthsSet.sorted()
recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
}
private func recalculateSimulatedLine(
sources: [SimulatorSource],
sourceMonthlyValues: [UUID: [(date: Date, value: Double)]],
allMonths: [Date]
) {
guard !allMonths.isEmpty, !sources.isEmpty else {
simulatorData = []
return
}
// Normalize weights
let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct }
guard totalSimulated > 0 else {
simulatorData = []
return
}
let normalizedWeights: [UUID: Double] = Dictionary(
uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) }
)
// Build per-source monthly return series
var sourceReturns: [UUID: [Date: Double]] = [:]
for source in sources {
guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue }
var returns: [Date: Double] = [:]
for i in 1..<monthly.count {
let prev = monthly[i - 1].value
let curr = monthly[i].value
if prev > 0 {
returns[monthly[i].date] = (curr - prev) / prev
}
}
sourceReturns[source.id] = returns
}
// Simulate portfolio
var simulated = 100.0
var simulatedSeries: [(date: Date, value: Double)] = []
if let firstMonth = allMonths.first {
simulatedSeries.append((date: firstMonth, value: simulated))
}
for i in 1..<allMonths.count {
let month = allMonths[i]
var portfolioReturn = 0.0
for source in sources {
let weight = normalizedWeights[source.id] ?? 0
let ret = sourceReturns[source.id]?[month] ?? 0
portfolioReturn += weight * ret
}
simulated *= (1 + portfolioReturn)
simulatedSeries.append((date: month, value: simulated))
}
simulatorData = simulatedSeries
}
// MARK: - Period Comparison Chart Calculation
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
guard !periodSnapshots.isEmpty else { return nil }
// Group by month, compute portfolio total
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
}
var monthlyTotalsArr: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {
var latestBySource: [UUID: Snapshot] = [:]
for snap in snaps {
guard let sourceId = snap.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snap.date > existing.date { latestBySource[sourceId] = snap }
} else {
latestBySource[sourceId] = snap
}
}
let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue }
let date = Calendar.current.date(from: key) ?? Date()
monthlyTotalsArr.append((date: date, value: total))
}
monthlyTotalsArr.sort { $0.date < $1.date }
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
let points = monthlyTotalsArr.enumerated().map { index, item in
(monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0)
}
return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points)
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM yyyy"
let labelA = "\(dateFormatter.string(from: periodAStart))\(dateFormatter.string(from: periodAEnd))"
let labelB = "\(dateFormatter.string(from: periodBStart))\(dateFormatter.string(from: periodBEnd))"
var result: [PeriodSeries] = []
if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd,
label: labelA, colorHex: "#3478F6", id: "A") {
result.append(seriesA)
}
if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd,
label: labelB, colorHex: "#FF9500", id: "B") {
result.append(seriesB)
}
periodComparisonData = result
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)