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
+8 -8
View File
@@ -459,7 +459,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -498,7 +498,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -655,7 +655,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -688,7 +688,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -719,7 +719,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -743,7 +743,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -766,7 +766,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -789,7 +789,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -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)
@@ -0,0 +1,241 @@
import SwiftUI
import Charts
struct AllocationSimulatorView: View {
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Allocation Simulator")
.font(.headline)
if viewModel.simulatorSources.isEmpty {
emptyStateView
} else {
allocationCard
chartSection
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No sources available")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
}
// MARK: - Allocation Card
private var allocationCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Adjust Allocation")
.font(.subheadline.weight(.semibold))
Spacer()
totalAllocationBadge
resetButton
}
ForEach(Array(viewModel.simulatorSources.enumerated()), id: \.element.id) { index, source in
sourceSliderRow(index: index, source: source)
}
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private var totalAllocationBadge: some View {
let total = viewModel.simulatorSources.reduce(0) { $0 + $1.simulatedPct }
let isNear100 = abs(total - 100) < 5
return Text(String(format: "%.0f%%", total))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.foregroundColor(isNear100 ? .green : .orange)
.cornerRadius(8)
}
private var resetButton: some View {
Button {
var updated = viewModel.simulatorSources
for i in updated.indices {
updated[i] = ChartsViewModel.SimulatorSource(
id: updated[i].id,
name: updated[i].name,
currentPct: updated[i].currentPct,
simulatedPct: updated[i].currentPct,
colorHex: updated[i].colorHex
)
}
viewModel.simulatorSources = updated
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
private func sourceSliderRow(index: Int, source: ChartsViewModel.SimulatorSource) -> some View {
VStack(spacing: 4) {
HStack {
Circle()
.fill(Color(hex: source.colorHex) ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
Spacer()
Text(String(format: "%.0f%%", source.simulatedPct))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.frame(width: 36, alignment: .trailing)
Text(String(format: "(%.0f%%)", source.currentPct))
.font(.caption2)
.foregroundColor(.secondary)
}
Slider(
value: Binding<Double>(
get: { source.simulatedPct },
set: { newValue in
var updated = viewModel.simulatorSources
if index < updated.count {
updated[index] = ChartsViewModel.SimulatorSource(
id: source.id,
name: source.name,
currentPct: source.currentPct,
simulatedPct: newValue,
colorHex: source.colorHex
)
viewModel.simulatorSources = updated
}
}
),
in: 0...100,
step: 1
)
.tint(Color(hex: source.colorHex) ?? .appPrimary)
}
}
// MARK: - Chart Section
private var chartSection: some View {
VStack(alignment: .leading, spacing: 8) {
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
Text("Not enough data to simulate.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(height: 220)
} else {
simulationChart
chartLegend
}
}
}
private var simulationChart: some View {
Chart {
ForEach(viewModel.simulatorActualData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Actual")
)
.foregroundStyle(Color.blue)
.interpolationMethod(.catmullRom)
}
ForEach(viewModel.simulatorData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Simulated")
)
.foregroundStyle(Color.orange)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 3]))
.interpolationMethod(.catmullRom)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.0f", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 220)
.drawingGroup()
}
private var chartLegend: some View {
HStack(spacing: 20) {
legendItem(color: .blue, label: "Actual", dashed: false)
legendItem(color: .orange, label: "Simulated", dashed: true)
Spacer()
}
}
private func legendItem(color: Color, label: String, dashed: Bool) -> some View {
HStack(spacing: 6) {
if dashed {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
}
} else {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 20, height: 3)
}
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let count = max(viewModel.simulatorActualData.count, viewModel.simulatorData.count)
switch count {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
}
@@ -192,6 +192,10 @@ struct ChartsContainerView: View {
private var hasAnyFilter: Bool {
let chartType = viewModel.selectedChartType
// These chart types manage their own controls internally, no external filter bar needed
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
return !viewModel.availableTimeRanges(for: chartType).isEmpty
}
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
@@ -498,6 +502,12 @@ struct ChartsContainerView: View {
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
case .comparison:
ComparisonChartView(viewModel: viewModel)
case .simulator:
AllocationSimulatorView(viewModel: viewModel)
case .periodComparison:
PeriodComparisonChartView(viewModel: viewModel)
}
}
}
@@ -0,0 +1,188 @@
import SwiftUI
import Charts
struct ComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Compare Sources")
.font(.headline)
sourceSelector
Picker("", selection: $viewModel.comparisonDisplayMode) {
ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Source Selector
private var sourceSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
let chipColor = palette[index % palette.count]
Button {
if isSelected {
viewModel.comparisonSelectedSourceIds.remove(source.id)
} else {
viewModel.comparisonSelectedSourceIds.insert(source.id)
}
} label: {
HStack(spacing: 6) {
Circle()
.fill(chipColor)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.comparisonSelectedSourceIds.isEmpty {
emptySelectionView
} else if viewModel.comparisonData.isEmpty {
noDataView
} else {
lineChart
}
}
private var emptySelectionView: some View {
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("Select 2+ sources to compare")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
private var noDataView: some View {
Text("No data available for selected sources in this period.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.frame(height: 260)
}
private var lineChart: some View {
VStack(spacing: 12) {
Chart {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
let seriesColor = palette[index % palette.count]
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Value", point.value),
series: .value("Source", series.name)
)
.foregroundStyle(seriesColor)
.interpolationMethod(.catmullRom)
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(yAxisLabel(for: d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.drawingGroup()
legend
}
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(palette[index % palette.count])
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
switch maxPoints {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
private func yAxisLabel(for value: Double) -> String {
switch viewModel.comparisonDisplayMode {
case .indexed:
return String(format: "%.0f", value)
case .returnPct:
return String(format: "%.1f%%", value)
case .absolute:
return Decimal(value).shortCurrencyString
}
}
}
@@ -0,0 +1,233 @@
import SwiftUI
import Charts
struct PeriodComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Period vs Period")
.font(.headline)
periodPickersSection
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Period Pickers
private var periodPickersSection: some View {
VStack(spacing: 10) {
periodRow(
label: "Period A",
color: Color(hex: "#3478F6") ?? .blue,
start: $viewModel.periodAStart,
end: $viewModel.periodAEnd
)
periodRow(
label: "Period B",
color: Color(hex: "#FF9500") ?? .orange,
start: $viewModel.periodBStart,
end: $viewModel.periodBEnd
)
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private func periodRow(
label: String,
color: Color,
start: Binding<Date>,
end: Binding<Date>
) -> some View {
HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption.weight(.semibold))
.foregroundColor(color)
.frame(width: 58, alignment: .leading)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(start.wrappedValue) },
set: { start.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
Text("")
.font(.caption)
.foregroundColor(.secondary)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(end.wrappedValue) },
set: { end.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.periodComparisonData.isEmpty {
emptyView
} else {
VStack(spacing: 12) {
periodChart
legend
}
}
}
private var emptyView: some View {
VStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No data available for the selected periods.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
// MARK: - Flat data for Chart
private struct PeriodPoint: Identifiable {
let id: String
let seriesId: String
let seriesLabel: String
let colorHex: String
let monthOffset: Int
let returnPct: Double
}
private var flatPoints: [PeriodPoint] {
var result: [PeriodPoint] = []
for series in viewModel.periodComparisonData {
for point in series.points {
result.append(PeriodPoint(
id: "\(series.id)-\(point.monthOffset)",
seriesId: series.id,
seriesLabel: series.label,
colorHex: series.colorHex,
monthOffset: point.monthOffset,
returnPct: point.returnPct
))
}
}
return result
}
private var periodChart: some View {
let points = flatPoints
let seriesIds = viewModel.periodComparisonData.map { $0.id }
let seriesColors: [Color] = viewModel.periodComparisonData.map {
Color(hex: $0.colorHex) ?? .gray
}
return Chart(points) { point in
LineMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct),
series: .value("Period", point.seriesLabel)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Period", point.seriesLabel))
PointMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(20)
}
.chartForegroundStyleScale(
domain: viewModel.periodComparisonData.map { $0.label },
range: seriesColors
)
.chartXAxis {
AxisMarks(values: xAxisValues) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel {
if let idx = value.as(Int.self) {
Text("M\(idx + 1)")
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.1f%%", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.drawingGroup()
.onChange(of: seriesIds) { _, _ in } // silence unused warning
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(viewModel.periodComparisonData) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series.colorHex) ?? .gray)
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Helpers
private var xAxisValues: [Int] {
let maxOffset = viewModel.periodComparisonData
.flatMap { $0.points }
.map { $0.monthOffset }
.max() ?? 0
return Array(0...maxOffset)
}
private func firstOfMonth(_ date: Date) -> Date {
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month], from: date)
components.day = 1
return calendar.date(from: components) ?? date
}
}