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 {
@@ -1,6 +1,7 @@
import Foundation
import Combine
import CoreData
import SwiftUI
@MainActor
class DashboardViewModel: ObservableObject {
@@ -646,6 +647,82 @@ class DashboardViewModel: ObservableObject {
sourcesNeedingUpdate.count
}
var insights: [PortfolioInsight] {
var result: [PortfolioInsight] = []
guard portfolioSummary.totalValue > 0 else { return result }
let total = portfolioSummary.totalValue
// 1. Milestone approaching (within 10% of next round milestone)
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000,
2_500_000, 5_000_000, 10_000_000]
if let next = milestones.first(where: { $0 > total }) {
let pct = NSDecimalNumber(decimal: total / next).doubleValue
if pct >= 0.90 {
let gap = next - total
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
result.append(PortfolioInsight(
id: "milestone",
systemImage: "flag.checkered",
title: String(localized: "insight_milestone_title"),
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
accentColor: .orange
))
}
}
// 2. Year-to-date performance
let ytdPct = portfolioSummary.yearChangePercentage
if abs(ytdPct) >= 0.1 {
let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
result.append(PortfolioInsight(
id: "ytd",
systemImage: icon,
title: String(localized: "insight_ytd_title"),
value: String(format: "%+.1f%%", ytdPct),
accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed
))
}
// 3. All-time market gains
let gains = portfolioSummary.allTimeReturn
if gains > 0 {
let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0)
result.append(PortfolioInsight(
id: "market_gains",
systemImage: "chart.line.uptrend.xyaxis",
title: String(localized: "insight_market_gains_title"),
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
accentColor: .appPrimary
))
}
// 4. Tracking streak (>= 3 months)
if updateStreak >= 3 {
result.append(PortfolioInsight(
id: "streak",
systemImage: "flame.fill",
title: String(localized: "insight_streak_title"),
value: String(format: String(localized: "insight_streak_value"), updateStreak),
accentColor: .orange
))
}
// 5. Forecast
if let forecast = portfolioForecast, forecast.forecastValue > total {
result.append(PortfolioInsight(
id: "forecast",
systemImage: "wand.and.stars",
title: String(localized: "insight_forecast_title"),
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
accentColor: .purple
))
}
return result
}
var topCategories: [CategoryMetrics] {
Array(categoryMetrics.prefix(5))
}
@@ -30,6 +30,11 @@ class SnapshotFormViewModel: ObservableObject {
let mode: Mode
let source: InvestmentSource
/// Contribution stored on the snapshot when editing started (nil in add mode or
/// when the snapshot had no contribution). Used to detect whether the user changed
/// the contribution and should be offered to propagate it to other snapshots.
private(set) var originalContribution: Decimal?
// MARK: - Dependencies
private var cancellables = Set<AnyCancellable>()
@@ -63,6 +68,7 @@ class SnapshotFormViewModel: ObservableObject {
if let contribution = snapshot.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
originalContribution = contribution.decimalValue
}
notes = snapshot.notes ?? ""
}
@@ -128,6 +134,12 @@ class SnapshotFormViewModel: ObservableObject {
return parseDecimal(trimmed)
}
/// True when the current contribution differs from the value the snapshot had when
/// editing started. Drives the "propagate to other snapshots" prompt.
var contributionChanged: Bool {
contribution != originalContribution
}
var formattedValue: String {
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
return value.currencyString