From 12ab2d600976c65ee4aaa7fdf5f2021a3ecfab19 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Tue, 26 May 2026 15:32:57 +0200 Subject: [PATCH] Add 3 interactive chart types: Compare, What If, Period vs Period (build 34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PortfolioJournal.xcodeproj/project.pbxproj | 16 +- .../ViewModels/ChartsViewModel.swift | 449 +++++++++++++++++- .../Charts/AllocationSimulatorView.swift | 241 ++++++++++ .../Views/Charts/ChartsContainerView.swift | 10 + .../Views/Charts/ComparisonChartView.swift | 188 ++++++++ .../Charts/PeriodComparisonChartView.swift | 233 +++++++++ 6 files changed, 1125 insertions(+), 12 deletions(-) create mode 100644 PortfolioJournal/Views/Charts/AllocationSimulatorView.swift create mode 100644 PortfolioJournal/Views/Charts/ComparisonChartView.swift create mode 100644 PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index d73c5fb..4a2f9e2 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -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; diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 505bb63..427bdf9 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -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 = [] + @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() + 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() + 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.. 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.. 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) diff --git a/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift b/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift new file mode 100644 index 0000000..345c2c5 --- /dev/null +++ b/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift @@ -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( + 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 + } + } +} diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 0688243..8874f85 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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) } } } diff --git a/PortfolioJournal/Views/Charts/ComparisonChartView.swift b/PortfolioJournal/Views/Charts/ComparisonChartView.swift new file mode 100644 index 0000000..a510add --- /dev/null +++ b/PortfolioJournal/Views/Charts/ComparisonChartView.swift @@ -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 + } + } +} diff --git a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift new file mode 100644 index 0000000..559fd40 --- /dev/null +++ b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift @@ -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, + end: Binding + ) -> 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( + 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( + 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 + } +}