1.6.0 #6+#7: proyección/ETA de objetivos + insights inteligentes

Goal ETA (#6): GoalProjection engine (CAGR mensual robusto de la evolución +
aportaciones, proyecta mes a mes → fecha estimada, estado ahead/on-track/behind/
unreachable, sparkline). GoalsView muestra 'Projected: <mes>', chip de ritmo con color,
fallback 'Add contributions to reach this'. GoalsViewModel.projection(for:) cacheado.

Insights (#7): InsightsEngine rule-based on-device (sin red/AI). Reglas: allocation vs
target, riesgo de concentración, mejor/peor categoría, diversificación, racha, YTD,
ganancias, forecast, proximidad a milestone. Priorizados por tono, top 4. PortfolioInsight
gana detail + tone. Surface en el Dashboard.

Localización: 19 claves nuevas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-25 12:22:12 +02:00
parent fd1094b4fc
commit 6887d05b01
14 changed files with 766 additions and 128 deletions
@@ -166,70 +166,62 @@ class GoalsViewModel: ObservableObject {
}
func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
projection(for: goal).projectedDate
}
/// Full forward projection for a goal, blending recent growth trend with the
/// monthly contributions of the relevant sources. Cached per goal.
func projection(for goal: Goal) -> GoalProjection.Result {
let sources = relevantSources(for: goal)
let currentValue = totalValue(for: goal)
guard !sources.isEmpty else {
let cached = GoalProjection.Result(
projectedDate: nil, monthsToReach: nil,
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
monthlyGrowthRate: 0, sparkline: []
)
cachedCompletionDates[goal.id] = nil
return cached
}
let sources: [InvestmentSource]
let evolutionData = evolutionData(for: goal, sources: sources)
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
let contribution = sources.reduce(Decimal.zero) {
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
}
let result = GoalProjection.project(
currentValue: currentValue,
targetAmount: goal.targetDecimal,
monthlyGrowthRate: rate,
monthlyContribution: contribution,
targetDate: goal.targetDate
)
cachedCompletionDates[goal.id] = result.projectedDate
return result
}
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
if let accountId = goal.account?.safeId {
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
} else {
sources = sourceRepository.sources
return sourceRepository.sources.filter { $0.account?.id == accountId }
}
return sourceRepository.sources
}
private func evolutionData(
for goal: Goal,
sources: [InvestmentSource]
) -> [(date: Date, value: Decimal)] {
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
if let cached = cachedEvolutionData[cacheKey] {
return cached
}
let sourceIds = sources.compactMap { $0.id }
guard !sourceIds.isEmpty else {
cachedCompletionDates[goal.id] = nil
return nil
}
// Performance: Use cached evolution data if available
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let evolutionData: [(date: Date, value: Decimal)]
if let cached = cachedEvolutionData[cacheKey] {
evolutionData = cached
} else {
let snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
evolutionData = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = evolutionData
}
guard evolutionData.count >= 3,
let first = evolutionData.suffix(6).first,
let last = evolutionData.suffix(6).last else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let currentValue = totalValue(for: goal)
let remaining = goal.targetDecimal - currentValue
guard remaining > 0 else {
let result = Date()
cachedCompletionDates[goal.id] = result
return result
}
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
let monthsRounded = Int(ceil(months))
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
cachedCompletionDates[goal.id] = result
return result
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
let data = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = data
return data
}
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {