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
+72 -6
View File
@@ -74,6 +74,8 @@ struct InvestmentWidgetEntry: TimelineEntry {
let categoryTotals: [(name: String, value: Decimal, color: String)]
let goals: [GoalSummary]
let currencyCode: String
let insightTitle: String
let insightValue: String
}
struct CategorySeries: Identifiable {
@@ -139,7 +141,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
goals: [
GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil)
],
currencyCode: "EUR"
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €50K"
)
}
@@ -176,7 +180,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: currencyCode
currencyCode: currencyCode,
insightTitle: "",
insightValue: ""
)
}
@@ -362,6 +368,41 @@ struct InvestmentWidgetProvider: TimelineProvider {
}
}
// Compute top insight
var insightTitle = ""
var insightValue = ""
// Milestone approaching
let milestones: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000]
let totalDouble = NSDecimalNumber(decimal: totalValue).doubleValue
if let nextMs = milestones.first(where: { $0 > totalDouble }) {
let pct = totalDouble / nextMs
if pct >= 0.90 {
let gap = nextMs - totalDouble
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
formatter.maximumFractionDigits = 0
let msStr = formatter.string(from: NSNumber(value: nextMs)) ?? ""
let gapStr = formatter.string(from: NSNumber(value: gap)) ?? ""
insightTitle = "Milestone ahead"
insightValue = "\(gapStr) from \(msStr)"
}
}
// Year performance (only if no milestone insight)
if insightTitle.isEmpty && !trendPoints.isEmpty && trendPoints.count >= 2 {
let first = NSDecimalNumber(decimal: trendPoints.first!).doubleValue
let last = NSDecimalNumber(decimal: trendPoints.last!).doubleValue
if first > 0 {
let pct = (last - first) / first * 100
if abs(pct) >= 1 {
insightTitle = "Year to date"
insightValue = String(format: "%+.1f%%", pct)
}
}
}
return InvestmentWidgetEntry(
date: Date(),
isPremium: isPremium,
@@ -374,7 +415,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
categoryEvolution: categoryEvolution,
categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) },
goals: goals,
currencyCode: currencyCode
currencyCode: currencyCode,
insightTitle: insightTitle,
insightValue: insightValue
)
}
}
@@ -498,6 +541,23 @@ struct MediumWidgetView: View {
}
}
}
if !entry.insightTitle.isEmpty {
HStack(spacing: 4) {
Image(systemName: "lightbulb.fill")
.font(.caption2)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 0) {
Text(entry.insightTitle)
.font(.caption2)
.foregroundColor(.secondary)
Text(entry.insightValue)
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
}
}
}
}
}
.padding()
@@ -930,7 +990,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: "EUR"
currencyCode: "EUR",
insightTitle: "",
insightValue: ""
)
}
@@ -953,7 +1015,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: "EUR"
currencyCode: "EUR",
insightTitle: "Year to date",
insightValue: "+12.4%"
)
}
@@ -1004,7 +1068,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
goals: [
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
],
currencyCode: "EUR"
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €100K"
)
}
extension Decimal {