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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user