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
@@ -33,7 +33,13 @@ struct InsightsRow: View {
Text(insight.value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
.lineLimit(2)
if let detail = insight.detail {
Text(detail)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
+99 -3
View File
@@ -36,7 +36,7 @@ struct GoalsView: View {
progress: viewModel.progress(for: goal),
totalValue: viewModel.totalValue(for: goal),
paceStatus: viewModel.paceStatus(for: goal),
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
projection: viewModel.projection(for: goal),
onEdit: { editingGoal = goal }
)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -216,7 +216,7 @@ struct GoalRowView: View {
let progress: Double
let totalValue: Decimal
let paceStatus: GoalPaceStatus?
let estimatedCompletionDate: Date?
let projection: GoalProjection.Result
let onEdit: () -> Void
@State private var showingShareOptions = false
@@ -294,6 +294,10 @@ struct GoalRowView: View {
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
)
}
if !isAchieved {
projectionRow
}
}
Spacer(minLength: 0)
}
@@ -332,10 +336,102 @@ struct GoalRowView: View {
currentValue: totalValue,
targetValue: goal.targetDecimal,
targetDate: goal.targetDate,
estimatedCompletionDate: estimatedCompletionDate,
estimatedCompletionDate: projection.projectedDate,
privacyMode: privacyMode
)
}
// MARK: - Projection UI
/// Projected completion date, an on-track chip, and a tiny projection sparkline.
@ViewBuilder
private var projectionRow: some View {
switch projection.status {
case .unreachable:
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
.font(.caption)
.foregroundColor(.secondary)
default:
HStack(spacing: 8) {
if let date = projection.projectedDate {
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
.font(.caption)
.foregroundColor(.secondary)
}
onTrackChip
Spacer(minLength: 0)
if projection.sparkline.count >= 2 {
ProjectionSparkline(values: projection.sparkline, tint: chipColor)
.frame(width: 48, height: 16)
}
}
}
}
private var chipColor: Color {
switch projection.status {
case .aheadOfSchedule: return .appSuccess
case .onTrack: return .positiveGreen
case .behind: return .appWarning
case .noTargetDate: return .secondary
case .unreachable: return .secondary
}
}
private var chipText: String {
switch projection.status {
case .aheadOfSchedule: return String(localized: "goal_chip_ahead")
case .onTrack: return String(localized: "goal_chip_on_track")
case .behind: return String(localized: "goal_chip_behind")
case .noTargetDate: return String(localized: "goal_chip_at_this_pace")
case .unreachable: return ""
}
}
@ViewBuilder
private var onTrackChip: some View {
if projection.status != .unreachable {
Text(chipText)
.font(.caption2.weight(.semibold))
.foregroundColor(chipColor)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(chipColor.opacity(0.15))
.clipShape(Capsule())
}
}
}
/// Minimal filled sparkline for the goal value projection curve.
private struct ProjectionSparkline: View {
let values: [Decimal]
let tint: Color
var body: some View {
GeometryReader { geo in
let points = normalizedPoints(in: geo.size)
ZStack {
if points.count >= 2 {
Path { path in
path.move(to: points[0])
for p in points.dropFirst() { path.addLine(to: p) }
}
.stroke(tint, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
}
}
}
}
private func normalizedPoints(in size: CGSize) -> [CGPoint] {
let doubles = values.map { NSDecimalNumber(decimal: $0).doubleValue }
guard let minV = doubles.min(), let maxV = doubles.max(), doubles.count >= 2 else { return [] }
let range = max(maxV - minV, 0.0001)
return doubles.enumerated().map { index, value in
let x = size.width * CGFloat(index) / CGFloat(doubles.count - 1)
let y = size.height * (1 - CGFloat((value - minV) / range))
return CGPoint(x: x, y: y)
}
}
}
#Preview {