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
@@ -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))
}