7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
178 lines
6.4 KiB
Swift
178 lines
6.4 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct PerformanceBarChart: View {
|
|
let data: [(category: String, cagr: Double, color: String)]
|
|
var title: String = "Performance by Category"
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Text("Compound Annual Growth Rate (CAGR)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if !data.isEmpty {
|
|
ChartStatsRow(stats: perfStats)
|
|
}
|
|
|
|
if !data.isEmpty {
|
|
Chart(data, id: \.category) { item in
|
|
BarMark(
|
|
x: .value("Category", item.category),
|
|
y: .value("CAGR", item.cagr)
|
|
)
|
|
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
|
.cornerRadius(4)
|
|
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
|
Text(String(format: "%.1f%%", item.cagr))
|
|
.font(.caption2)
|
|
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks { value in
|
|
AxisValueLabel {
|
|
if let category = value.as(String.self) {
|
|
Text(category)
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let doubleValue = value.as(Double.self) {
|
|
Text(String(format: "%.0f%%", doubleValue))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 250)
|
|
|
|
// Legend / Details
|
|
VStack(spacing: 8) {
|
|
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
|
HStack {
|
|
Circle()
|
|
.fill(Color(hex: item.color) ?? .gray)
|
|
.frame(width: 10, height: 10)
|
|
|
|
Text(item.category)
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Text(String(format: "%.2f%%", item.cagr))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
} else {
|
|
Text("No performance data available")
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 250)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
private var perfStats: [ChartStat] {
|
|
guard !data.isEmpty else { return [] }
|
|
let values = data.map { $0.cagr }
|
|
let best = values.max() ?? 0
|
|
let worst = values.min() ?? 0
|
|
let avg = values.reduce(0, +) / Double(values.count)
|
|
return [
|
|
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
|
|
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
|
|
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
|
]
|
|
}
|
|
}
|
|
|
|
// MARK: - Horizontal Bar Version
|
|
|
|
struct HorizontalPerformanceChart: View {
|
|
let data: [(category: String, cagr: Double, color: String)]
|
|
|
|
var sortedData: [(category: String, cagr: Double, color: String)] {
|
|
data.sorted { $0.cagr > $1.cagr }
|
|
}
|
|
|
|
var maxValue: Double {
|
|
max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Performance by Category")
|
|
.font(.headline)
|
|
|
|
ForEach(sortedData, id: \.category) { item in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(item.category)
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Text(String(format: "%.2f%%", item.cagr))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
|
}
|
|
|
|
GeometryReader { geometry in
|
|
let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0
|
|
let barWidth = geometry.size.width * normalizedValue
|
|
|
|
ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) {
|
|
Rectangle()
|
|
.fill(Color.gray.opacity(0.1))
|
|
.frame(height: 8)
|
|
.cornerRadius(4)
|
|
|
|
Rectangle()
|
|
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
|
.frame(width: barWidth, height: 8)
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let sampleData: [(category: String, cagr: Double, color: String)] = [
|
|
("Stocks", 12.5, "#10B981"),
|
|
("Bonds", 4.2, "#3B82F6"),
|
|
("Real Estate", 8.1, "#F59E0B"),
|
|
("Crypto", -5.3, "#8B5CF6")
|
|
]
|
|
|
|
return VStack(spacing: 20) {
|
|
PerformanceBarChart(data: sampleData)
|
|
HorizontalPerformanceChart(data: sampleData)
|
|
}
|
|
.padding()
|
|
}
|