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
@@ -731,80 +731,45 @@ class DashboardViewModel: ObservableObject {
)
}
/// Prioritized, on-device insights. Gathers local inputs and delegates the
/// rules to `InsightsEngine` (no network / no AI).
var insights: [PortfolioInsight] {
var result: [PortfolioInsight] = []
guard portfolioSummary.totalValue > 0 else { return result }
let total = portfolioSummary.totalValue
guard total > 0 else { return [] }
// 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
))
}
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
InsightsEngine.CategorySlice(
name: cat.categoryName,
percentageOfPortfolio: cat.percentageOfPortfolio,
targetPercentage: AllocationTargetStore.target(for: cat.id),
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
)
}
// 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
))
let sources = filteredSources()
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
guard value > 0, totalDouble > 0 else { return nil }
return InsightsEngine.SourceSlice(
name: source.name,
percentageOfPortfolio: value / totalDouble * 100
)
}
// 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
))
}
let input = InsightsEngine.Input(
totalValue: total,
categories: categorySlices,
sources: sourceSlices,
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
fundedSourceCount: sourceSlices.count,
yearChangePercentage: portfolioSummary.yearChangePercentage,
allTimeReturn: portfolioSummary.allTimeReturn,
updateStreak: updateStreak,
forecast: portfolioForecast
)
// 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
return InsightsEngine.makeInsights(input)
}
var topCategories: [CategoryMetrics] {