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:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -26,6 +26,7 @@ struct ChartsContainerView: View {
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
}
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -46,6 +47,26 @@ struct ChartsContainerView: View {
}
}
// MARK: - Focus Mode Button
private var focusModeButton: some View {
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
Text(calmModeEnabled ? "Focus" : "Full")
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.cornerRadius(14)
}
.buttonStyle(.plain)
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
@@ -66,6 +87,35 @@ struct ChartsContainerView: View {
iPadChartTypeRow(chartType)
}
Divider().padding(.vertical, 8)
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
.font(.system(size: 14))
.foregroundColor(calmModeEnabled ? .white : .primary)
}
Text("Focus Mode")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.font(.system(size: 16))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
}
.buttonStyle(.plain)
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
@@ -209,13 +259,28 @@ struct ChartsContainerView: View {
VStack(alignment: .leading, spacing: 10) {
let chartType = viewModel.selectedChartType
// Time Range
if chartType != .allocation && chartType != .riskReturn {
// Time Range (not for performance uses slider)
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
filterRow(icon: "calendar", label: "Period") {
timeRangeSelector
}
}
// Performance: custom period slider
if chartType == .performance {
filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") {
Slider(
value: Binding(
get: { Double(viewModel.performancePeriodMonths) },
set: { viewModel.performancePeriodMonths = Int($0) }
),
in: 1...60,
step: 1
)
.tint(.appPrimary)
}
}
// Breakdown (category vs source)
if chartType == .allocation || chartType == .performance {
filterRow(icon: "square.grid.2x2", label: "Group") {
@@ -449,6 +514,16 @@ struct ChartsContainerView: View {
}
}
private func periodLabel(_ months: Int) -> String {
if months < 12 {
return "\(months)M"
} else if months % 12 == 0 {
return "\(months / 12)Y"
} else {
return "\(months / 12)Y \(months % 12)M"
}
}
// MARK: - Chart Content
@ViewBuilder
@@ -501,7 +576,7 @@ struct ChartsContainerView: View {
historicalData: viewModel.evolutionData
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
YearOverYearChartView(viewModel: viewModel)
case .comparison:
ComparisonChartView(viewModel: viewModel)
case .simulator:
@@ -696,6 +771,9 @@ struct EvolutionChartView: View {
headerView
modePicker
chartSection
if !data.isEmpty && chartMode == .total {
ChartStatsRow(stats: evolutionStats)
}
}
.padding()
.background(Color(.systemBackground))
@@ -703,6 +781,23 @@ struct EvolutionChartView: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var evolutionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
let latest = values.last ?? 0
let first = values.first ?? 0
let change = latest - first
let changePct = first > 0 ? (change / first) * 100 : 0
let maxVal = values.max() ?? 0
let minVal = values.min() ?? 0
return [
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed),
]
}
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
@@ -937,6 +1032,7 @@ struct ContributionsChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
.padding()
@@ -944,6 +1040,21 @@ struct ContributionsChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var contributionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
let total = amounts.reduce(0, +)
let avg = total / Double(amounts.count)
let highest = amounts.max() ?? 0
let last = amounts.last ?? 0
return [
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary),
ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary),
]
}
}
// MARK: - Rolling 12-Month Return
@@ -994,6 +1105,13 @@ struct RollingReturnChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
valueHeader: "Return",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
}
}
.padding()
@@ -1001,6 +1119,41 @@ struct RollingReturnChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var rollingStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.value }
let latest = values.last ?? 0
let best = values.max() ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
let change = (values.last ?? 0) - (values.first ?? 0)
return [
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
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),
ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed),
]
}
private var rollingTableRows: [ChartDataTableRow] {
guard !data.isEmpty else { return [] }
let firstVal = data.first?.value ?? 0
return data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].value : point.value
let deltaPrev = point.value - prevVal
let deltaFirst = point.value - firstVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.value),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
// MARK: - Risk vs Return
@@ -1115,6 +1268,7 @@ struct CashflowStackedChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}
.padding()
@@ -1122,6 +1276,18 @@ struct CashflowStackedChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var cashflowStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions }
let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance }
let net = totalContrib + totalPerf
return [
ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed),
]
}
}
// MARK: - Allocation Evolution Chart