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
+167 -75
View File
@@ -121,11 +121,13 @@ class ChartsViewModel: ObservableObject {
@Published var predictionData: [Prediction] = []
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
@Published var yearOverYearData: [YearSeries] = []
@Published var yoySelectedYears: Set<Int> = []
struct YearSeries: Identifiable {
let id: Int // year
let year: Int
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
let values: [Double] // cumulative % return within year (first available month = 0%), NaN = no data
var forecastEndValue: Double? = nil // estimated cumulative % at year-end (only for the current, still-incomplete year)
}
// MARK: - Comparison chart data models
@@ -134,6 +136,7 @@ class ChartsViewModel: ObservableObject {
case indexed = "Base 100"
case returnPct = "Return %"
case absolute = "Value"
case monthlyReturn = "Month %"
var id: String { rawValue }
}
@@ -146,7 +149,7 @@ class ChartsViewModel: ObservableObject {
@Published var comparisonData: [ComparisonSeries] = []
@Published var comparisonSelectedSourceIds: Set<UUID> = []
@Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed
@Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct
@Published var comparisonAvailableSources: [InvestmentSource] = []
// MARK: - Simulator chart data models
@@ -181,6 +184,7 @@ class ChartsViewModel: ObservableObject {
@Published var isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
@Published var performancePeriodMonths: Int = 12
// MARK: - Time Range
@@ -351,6 +355,15 @@ class ChartsViewModel: ObservableObject {
}
.store(in: &cancellables)
// Performance: react to custom period slider
$performancePeriodMonths
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.selectedChartType == .performance else { return }
self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange)
}
.store(in: &cancellables)
// Period comparison: react to date picker changes
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
@@ -396,10 +409,10 @@ class ChartsViewModel: ObservableObject {
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
switch chartType {
case .evolution, .performance:
case .evolution:
return [.all, .yearToDate, .year, .quarter]
case .yearOverYear, .simulator, .periodComparison:
return [] // No time range filter for these charts
case .performance, .yearOverYear, .simulator, .periodComparison:
return [] // Performance uses custom slider; others have no time range
case .comparison:
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
default:
@@ -469,7 +482,17 @@ class ChartsViewModel: ObservableObject {
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
calculateAllocationEvolutionData(from: completedSnapshots)
case .performance:
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
// Fetch with performancePeriodMonths (independent of selectedTimeRange)
let perfSourceIds = sources.compactMap { $0.id }
let perfAllSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1)
)
let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date()
let perfFiltered = filterSnapshotsForCharts(
sources: sources,
snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff }
)
let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered)
calculatePerformanceData(
for: sources,
snapshotsBySource: completedSnapshotsBySource,
@@ -584,7 +607,7 @@ class ChartsViewModel: ObservableObject {
}
/// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
private static let sourceColorHexes: [String] = [
static let sourceColorHexesPublic: [String] = [
"#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
"#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
]
@@ -594,7 +617,7 @@ class ChartsViewModel: ObservableObject {
let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
var map: [UUID: String] = [:]
for (index, source) in sorted.enumerated() {
map[source.id] = Self.sourceColorHexes[index % Self.sourceColorHexes.count]
map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count]
}
return map
}
@@ -961,17 +984,22 @@ class ChartsViewModel: ObservableObject {
.sorted { $0.date < $1.date }
}
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
guard monthlyTotals.count >= 13 else {
private func calculateRollingReturnData(from _: [Snapshot]) {
// Needs full history (not time-filtered) to have 13+ months
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
)
let totals = monthlyTotals(from: allSnapshots)
guard totals.count >= 13 else {
rollingReturnData = []
return
}
var returns: [(date: Date, value: Double)] = []
for index in 12..<monthlyTotals.count {
let current = monthlyTotals[index]
let base = monthlyTotals[index - 12]
for index in 12..<totals.count {
let current = totals[index]
let base = totals[index - 12]
guard base.totalValue > 0 else { continue }
let change = current.totalValue - base.totalValue
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
@@ -1034,51 +1062,60 @@ class ChartsViewModel: ObservableObject {
}
private func calculateDrawdownData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard !sortedSnapshots.isEmpty else {
let totals = monthlyTotals(from: snapshots)
guard !totals.isEmpty else {
drawdownData = []
return
}
var peak = sortedSnapshots.first!.decimalValue
var peak = totals.first!.totalValue
var data: [(date: Date, drawdown: Double)] = []
for snapshot in sortedSnapshots {
let value = snapshot.decimalValue
if value > peak {
peak = value
}
for point in totals {
let value = point.totalValue
if value > peak { peak = value }
let drawdown = peak > 0
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
: 0
data.append((date: snapshot.date, drawdown: -drawdown))
data.append((date: point.date, drawdown: -drawdown))
}
drawdownData = data
}
private func calculateVolatilityData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard sortedSnapshots.count >= 3 else {
let totals = monthlyTotals(from: snapshots)
guard totals.count >= 4 else {
volatilityData = []
return
}
var data: [(date: Date, volatility: Double)] = []
// Compute monthly return series from portfolio totals
var monthlyReturns: [Double] = []
var returnDates: [Date] = []
for i in 1..<totals.count {
let prev = totals[i - 1].totalValue
let curr = totals[i].totalValue
guard prev > 0 else { continue }
let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100
monthlyReturns.append(ret)
returnDates.append(totals[i].date)
}
let windowSize = 3
guard monthlyReturns.count >= windowSize else {
volatilityData = []
return
}
for i in windowSize..<sortedSnapshots.count {
let window = Array(sortedSnapshots[(i - windowSize)..<i])
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
let mean = values.reduce(0, +) / Double(values.count)
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
let stdDev = sqrt(variance)
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
data.append((date: sortedSnapshots[i].date, volatility: volatility))
// Rolling std dev of monthly returns (annualised)
var data: [(date: Date, volatility: Double)] = []
for i in (windowSize - 1)..<monthlyReturns.count {
let window = Array(monthlyReturns[(i - windowSize + 1)...i])
let mean = window.reduce(0, +) / Double(window.count)
let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(max(1, window.count - 1))
let stdDev = sqrt(max(0, variance))
data.append((date: returnDates[i], volatility: stdDev))
}
volatilityData = data
@@ -1089,39 +1126,33 @@ class ChartsViewModel: ObservableObject {
predictionData = []
return
}
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
let totals = monthlyTotals(from: snapshots)
let series = totals.map { (date: $0.date, value: $0.totalValue) }
let result = predictionEngine.predict(series: series, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
guard !snapshots.isEmpty else {
private func calculateYearOverYearData(from _: [Snapshot]) {
// Always use full history (not time-range filtered)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots = freemiumValidator.filterSnapshots(
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
)
let totals = monthlyTotals(from: allSnapshots)
guard !totals.isEmpty else {
yearOverYearData = []
return
}
// Build all monthly totals (no time range filter for YoY)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
}
let calendar = Calendar.current
// group by (year, month), take latest per source
var byYearMonth: [Int: [Int: Decimal]] = [:]
var latestBySource: [UUID: Snapshot] = [:]
let sorted = allSnapshots.sorted { $0.date < $1.date }
for snap in sorted {
guard let sourceId = snap.source?.id else { continue }
latestBySource[sourceId] = snap
let year = calendar.component(.year, from: snap.date)
let month = calendar.component(.month, from: snap.date) // 1-based
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
byYearMonth[year, default: [:]][month] = total
// Group monthly totals by (year, month)
var byYearMonth: [Int: [Int: Decimal]] = [:]
for point in totals {
let year = calendar.component(.year, from: point.date)
let month = calendar.component(.month, from: point.date)
byYearMonth[year, default: [:]][month] = point.totalValue
}
let years = byYearMonth.keys.sorted()
@@ -1130,25 +1161,70 @@ class ChartsViewModel: ObservableObject {
return
}
// Forward-fill within each year: carry previous month if no data
yearOverYearData = years.map { year in
var values = [Decimal](repeating: .zero, count: 12)
var last: Decimal = .zero
let nowYear = calendar.component(.year, from: Date())
let nowMonth = calendar.component(.month, from: Date())
// For each year, compute cumulative % return from the first available month (baseline = 0%)
yearOverYearData = years.map { year -> YearSeries in
var values = [Double](repeating: Double.nan, count: 12)
let yearData = byYearMonth[year] ?? [:]
let sortedMonths = yearData.keys.sorted()
guard let baselineMonth = sortedMonths.first,
let baselineDecimal = yearData[baselineMonth] else {
return YearSeries(id: year, year: year, values: values)
}
let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue
guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) }
for monthIdx in 0..<12 {
let month = monthIdx + 1
if let v = byYearMonth[year]?[month] {
last = v
if let v = yearData[month] {
let vDouble = NSDecimalNumber(decimal: v).doubleValue
values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0
}
values[monthIdx] = last
}
return YearSeries(id: year, year: year, values: values)
// #146: forecast the year-end value for the current, still-incomplete year so the
// "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12.
var forecastEnd: Double? = nil
if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 {
let monthsAhead = 12 - lastDataMonth
let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in
guard let v = yearData[m],
let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil }
return (date: d, value: v)
}
if absSeries.count >= 3 {
let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead)
if let predDec = result.predictions.last?.predictedValue {
let predDouble = NSDecimalNumber(decimal: predDec).doubleValue
forecastEnd = ((predDouble - baseline) / baseline) * 100.0
}
}
// Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December.
if forecastEnd == nil,
let firstIdx = (0..<12).first(where: { !values[$0].isNaN }),
let lastIdx = (0..<12).last(where: { !values[$0].isNaN }),
lastIdx > firstIdx {
let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx)
forecastEnd = monthlyRate * Double(11 - firstIdx)
}
}
return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd)
}
// Default-select the last 2 available years
let availableYears = Set(years)
if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) {
yoySelectedYears = Set(years.suffix(2))
}
}
// MARK: - Comparison Chart Calculation
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
let colorHexes = Self.sourceColorHexes
let colorHexes = Self.sourceColorHexesPublic
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
@@ -1191,6 +1267,13 @@ class ChartsViewModel: ObservableObject {
}
case .absolute:
points = monthlyValues
case .monthlyReturn:
points = monthlyValues.enumerated().map { idx, item in
guard idx > 0 else { return (date: item.date, value: 0.0) }
let prev = monthlyValues[idx - 1].value
let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0
return (date: item.date, value: pct)
}
}
let colorHex = colorHexes[index % colorHexes.count]
@@ -1215,7 +1298,7 @@ class ChartsViewModel: ObservableObject {
return
}
let colorHexes = Self.sourceColorHexes
let colorHexes = Self.sourceColorHexesPublic
let sortedSources = sources.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
@@ -1395,12 +1478,21 @@ class ChartsViewModel: ObservableObject {
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
// Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes,
// por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one).
// Cota superior exclusiva al inicio del mes siguiente incluye todo el último mes (simétrico A/B).
let rangeStart = start.startOfMonth
let rangeEnd = end.startOfMonth.adding(months: 1)
let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd }
guard !periodSnapshots.isEmpty else { return nil }
// Group by month, compute portfolio total
// Group by month, compute portfolio total.
// Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango).
// chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para
// snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer
// el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B).
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
chartMonth(for: snap.date)
Calendar.current.dateComponents([.year, .month], from: snap.date)
}
var monthlyTotalsArr: [(date: Date, value: Double)] = []
for (key, snaps) in grouped {