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 <noreply@anthropic.com>
This commit is contained in:
@@ -103,6 +103,7 @@ class ChartsViewModel: ObservableObject {
|
|||||||
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
||||||
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
||||||
@Published var predictionData: [Prediction] = []
|
@Published var predictionData: [Prediction] = []
|
||||||
|
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
|
||||||
|
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var showingPaywall = false
|
@Published var showingPaywall = false
|
||||||
@@ -340,6 +341,7 @@ class ChartsViewModel: ObservableObject {
|
|||||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||||
case .allocation:
|
case .allocation:
|
||||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||||
|
calculateAllocationEvolutionData(from: completedSnapshots)
|
||||||
case .performance:
|
case .performance:
|
||||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||||
calculatePerformanceData(
|
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(
|
private func completedMonthKeys(
|
||||||
sources: [InvestmentSource],
|
sources: [InvestmentSource],
|
||||||
snapshots: [Snapshot],
|
snapshots: [Snapshot],
|
||||||
|
|||||||
@@ -308,11 +308,17 @@ struct ChartsContainerView: View {
|
|||||||
goals: goalsViewModel.goals
|
goals: goalsViewModel.goals
|
||||||
)
|
)
|
||||||
case .allocation:
|
case .allocation:
|
||||||
AllocationPieChart(
|
VStack(spacing: 20) {
|
||||||
data: viewModel.allocationData,
|
AllocationPieChart(
|
||||||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
data: viewModel.allocationData,
|
||||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
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:
|
case .performance:
|
||||||
PerformanceBarChart(
|
PerformanceBarChart(
|
||||||
data: viewModel.performanceData,
|
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 {
|
#Preview {
|
||||||
ChartsContainerView(iapService: IAPService())
|
ChartsContainerView(iapService: IAPService())
|
||||||
.environmentObject(AccountStore(iapService: IAPService()))
|
.environmentObject(AccountStore(iapService: IAPService()))
|
||||||
|
|||||||
Reference in New Issue
Block a user