From c99398c350fa7576108706d0c7f53fb5deb5e57e Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 11 Feb 2026 23:54:43 +0100 Subject: [PATCH] Add allocation evolution chart showing allocation changes over time Adds a stacked area chart under the Allocation tab that displays how the portfolio allocation percentages have changed across months. Fixes #25 Co-Authored-By: Claude Opus 4.6 --- .../ViewModels/ChartsViewModel.swift | 53 ++++++++++++ .../Views/Charts/ChartsContainerView.swift | 80 +++++++++++++++++-- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 699bdfb..d3ca1bb 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -103,6 +103,7 @@ class ChartsViewModel: ObservableObject { @Published var drawdownData: [(date: Date, drawdown: Double)] = [] @Published var volatilityData: [(date: Date, volatility: Double)] = [] @Published var predictionData: [Prediction] = [] + @Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = [] @Published var isLoading = false @Published var showingPaywall = false @@ -340,6 +341,7 @@ class ChartsViewModel: ObservableObject { calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart) case .allocation: calculateAllocationData(for: sources, breakdown: selectedBreakdown) + calculateAllocationEvolutionData(from: completedSnapshots) case .performance: let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) calculatePerformanceData( @@ -587,6 +589,57 @@ class ChartsViewModel: ObservableObject { } } + private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) { + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in + Calendar.current.dateComponents([.year, .month], from: snapshot.date) + } + + let sortedMonths = groupedByMonth.keys.sorted { + let d1 = ($0.year ?? 0) * 100 + ($0.month ?? 0) + let d2 = ($1.year ?? 0) * 100 + ($1.month ?? 0) + return d1 < d2 + } + + var result: [(date: Date, category: String, percentage: Double, color: String)] = [] + + for monthKey in sortedMonths { + guard let monthSnapshots = groupedByMonth[monthKey], + let monthDate = Calendar.current.date(from: monthKey) else { continue } + + var categoryTotals: [String: (value: Decimal, color: String)] = [:] + var sourceLatest: [UUID: Snapshot] = [:] + + for snapshot in monthSnapshots { + guard let sourceId = snapshot.source?.id else { continue } + if let existing = sourceLatest[sourceId] { + if snapshot.date > existing.date { + sourceLatest[sourceId] = snapshot + } + } else { + sourceLatest[sourceId] = snapshot + } + } + + for (_, snapshot) in sourceLatest { + let categoryName = snapshot.source?.category?.name ?? "Other" + let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280" + let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex) + categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex) + } + + let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value } + guard total > 0 else { continue } + + for (category, info) in categoryTotals.sorted(by: { $0.value.value > $1.value.value }) { + let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue + result.append((date: monthDate, category: category, percentage: percentage, color: info.color)) + } + } + + allocationEvolutionData = result + } + private func completedMonthKeys( sources: [InvestmentSource], snapshots: [Snapshot], diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 909951e..291ecdb 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -308,11 +308,17 @@ struct ChartsContainerView: View { goals: goalsViewModel.goals ) case .allocation: - AllocationPieChart( - data: viewModel.allocationData, - title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation", - showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown) - ) + VStack(spacing: 20) { + AllocationPieChart( + data: viewModel.allocationData, + title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation", + showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown) + ) + + if !viewModel.allocationEvolutionData.isEmpty { + AllocationEvolutionChart(data: viewModel.allocationEvolutionData) + } + } case .performance: PerformanceBarChart( data: viewModel.performanceData, @@ -920,6 +926,70 @@ struct CashflowStackedChartView: View { } } +// MARK: - Allocation Evolution Chart + +struct AllocationEvolutionChart: View { + let data: [(date: Date, category: String, percentage: Double, color: String)] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Allocation Over Time") + .font(.headline) + + if data.isEmpty { + Text("Not enough data to show allocation evolution.") + .foregroundColor(.secondary) + .frame(height: 260) + } else { + Chart { + ForEach(data, id: \.date.description + \.category) { item in + AreaMark( + x: .value("Date", item.date), + y: .value("Percentage", item.percentage) + ) + .foregroundStyle(by: .value("Category", item.category)) + } + } + .chartForegroundStyleScale(domain: categoryNames, range: categoryColors) + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { _ in + AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let pct = value.as(Double.self) { + Text(String(format: "%.0f%%", pct)) + .font(.caption) + } + } + } + } + .frame(height: 260) + .drawingGroup() + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + private var categoryNames: [String] { + Array(Set(data.map { $0.category })).sorted() + } + + private var categoryColors: [Color] { + categoryNames.map { name in + if let hex = data.first(where: { $0.category == name })?.color { + return Color(hex: hex) ?? .gray + } + return .gray + } + } +} + #Preview { ChartsContainerView(iapService: IAPService()) .environmentObject(AccountStore(iapService: IAPService()))