1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad
- 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
This commit is contained in:
@@ -4,6 +4,9 @@ import Charts
|
||||
struct PeriodComparisonChartView: View {
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
|
||||
private let colorA = Color(hex: "#3478F6") ?? .blue
|
||||
private let colorB = Color(hex: "#FF9500") ?? .orange
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Period vs Period")
|
||||
@@ -21,39 +24,37 @@ struct PeriodComparisonChartView: View {
|
||||
// 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
|
||||
)
|
||||
VStack(spacing: 8) {
|
||||
periodRow(label: "Period A", color: colorA, start: $viewModel.periodAStart, end: $viewModel.periodAEnd)
|
||||
Divider()
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
periodRow(label: "Period B", color: colorB, start: $viewModel.periodBStart, end: $viewModel.periodBEnd)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [colorA.opacity(0.06), colorB.opacity(0.06)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func periodRow(
|
||||
label: String,
|
||||
color: Color,
|
||||
start: Binding<Date>,
|
||||
end: Binding<Date>
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
private func periodRow(label: String, color: Color, start: Binding<Date>, end: Binding<Date>) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
.frame(width: 3, height: 30)
|
||||
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 58, alignment: .leading)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
DatePicker(
|
||||
"",
|
||||
@@ -67,7 +68,7 @@ struct PeriodComparisonChartView: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text("–")
|
||||
Text("→")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -92,13 +93,73 @@ struct PeriodComparisonChartView: View {
|
||||
if viewModel.periodComparisonData.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 16) {
|
||||
numericalSummary
|
||||
periodChart
|
||||
legend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var numericalSummary: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.periodComparisonData) { series in
|
||||
let color = Color(hex: series.colorHex) ?? .gray
|
||||
let finalReturn = series.points.last?.returnPct ?? 0
|
||||
let maxReturn = series.points.map { $0.returnPct }.max() ?? 0
|
||||
let minReturn = series.points.map { $0.returnPct }.min() ?? 0
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(color)
|
||||
.frame(width: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(series.label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(String(format: "%+.2f%%", finalReturn))
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
Text(String(format: "%.1f%%", maxReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
Text(String(format: "%.1f%%", minReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(color.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(color.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
@@ -148,21 +209,28 @@ struct PeriodComparisonChartView: View {
|
||||
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))
|
||||
return Chart {
|
||||
ForEach(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))
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||||
|
||||
PointMark(
|
||||
x: .value("Month", point.monthOffset),
|
||||
y: .value("Return", point.returnPct)
|
||||
)
|
||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||
.symbolSize(20)
|
||||
PointMark(
|
||||
x: .value("Month", point.monthOffset),
|
||||
y: .value("Return", point.returnPct)
|
||||
)
|
||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||
.symbolSize(30)
|
||||
}
|
||||
|
||||
RuleMark(y: .value("Zero", 0))
|
||||
.foregroundStyle(Color.secondary.opacity(0.4))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
}
|
||||
.chartForegroundStyleScale(
|
||||
domain: viewModel.periodComparisonData.map { $0.label },
|
||||
@@ -194,23 +262,22 @@ struct PeriodComparisonChartView: View {
|
||||
}
|
||||
.frame(height: 260)
|
||||
.drawingGroup()
|
||||
.onChange(of: seriesIds) { _, _ in } // silence unused warning
|
||||
.onChange(of: seriesIds) { _, _ in }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user