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:
@@ -121,11 +121,13 @@ class ChartsViewModel: ObservableObject {
|
||||
@Published var predictionData: [Prediction] = []
|
||||
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
|
||||
@Published var yearOverYearData: [YearSeries] = []
|
||||
@Published var yoySelectedYears: Set<Int> = []
|
||||
|
||||
struct YearSeries: Identifiable {
|
||||
let id: Int // year
|
||||
let year: Int
|
||||
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
|
||||
let values: [Double] // cumulative % return within year (first available month = 0%), NaN = no data
|
||||
var forecastEndValue: Double? = nil // estimated cumulative % at year-end (only for the current, still-incomplete year)
|
||||
}
|
||||
|
||||
// MARK: - Comparison chart data models
|
||||
@@ -134,6 +136,7 @@ class ChartsViewModel: ObservableObject {
|
||||
case indexed = "Base 100"
|
||||
case returnPct = "Return %"
|
||||
case absolute = "Value"
|
||||
case monthlyReturn = "Month %"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -146,7 +149,7 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
@Published var comparisonData: [ComparisonSeries] = []
|
||||
@Published var comparisonSelectedSourceIds: Set<UUID> = []
|
||||
@Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed
|
||||
@Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct
|
||||
@Published var comparisonAvailableSources: [InvestmentSource] = []
|
||||
|
||||
// MARK: - Simulator chart data models
|
||||
@@ -181,6 +184,7 @@ class ChartsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@Published private var predictionMonthsAhead = 12
|
||||
@Published var performancePeriodMonths: Int = 12
|
||||
|
||||
// MARK: - Time Range
|
||||
|
||||
@@ -351,6 +355,15 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Performance: react to custom period slider
|
||||
$performancePeriodMonths
|
||||
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.selectedChartType == .performance else { return }
|
||||
self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Period comparison: react to date picker changes
|
||||
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
@@ -396,10 +409,10 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
|
||||
switch chartType {
|
||||
case .evolution, .performance:
|
||||
case .evolution:
|
||||
return [.all, .yearToDate, .year, .quarter]
|
||||
case .yearOverYear, .simulator, .periodComparison:
|
||||
return [] // No time range filter for these charts
|
||||
case .performance, .yearOverYear, .simulator, .periodComparison:
|
||||
return [] // Performance uses custom slider; others have no time range
|
||||
case .comparison:
|
||||
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
|
||||
default:
|
||||
@@ -469,7 +482,17 @@ class ChartsViewModel: ObservableObject {
|
||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||
calculateAllocationEvolutionData(from: completedSnapshots)
|
||||
case .performance:
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
// Fetch with performancePeriodMonths (independent of selectedTimeRange)
|
||||
let perfSourceIds = sources.compactMap { $0.id }
|
||||
let perfAllSnapshots = freemiumValidator.filterSnapshots(
|
||||
snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1)
|
||||
)
|
||||
let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date()
|
||||
let perfFiltered = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff }
|
||||
)
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered)
|
||||
calculatePerformanceData(
|
||||
for: sources,
|
||||
snapshotsBySource: completedSnapshotsBySource,
|
||||
@@ -584,7 +607,7 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
|
||||
private static let sourceColorHexes: [String] = [
|
||||
static let sourceColorHexesPublic: [String] = [
|
||||
"#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
|
||||
"#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
|
||||
]
|
||||
@@ -594,7 +617,7 @@ class ChartsViewModel: ObservableObject {
|
||||
let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
var map: [UUID: String] = [:]
|
||||
for (index, source) in sorted.enumerated() {
|
||||
map[source.id] = Self.sourceColorHexes[index % Self.sourceColorHexes.count]
|
||||
map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count]
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -961,17 +984,22 @@ class ChartsViewModel: ObservableObject {
|
||||
.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
|
||||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||
guard monthlyTotals.count >= 13 else {
|
||||
private func calculateRollingReturnData(from _: [Snapshot]) {
|
||||
// Needs full history (not time-filtered) to have 13+ months
|
||||
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
|
||||
let allSnapshots = freemiumValidator.filterSnapshots(
|
||||
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
)
|
||||
let totals = monthlyTotals(from: allSnapshots)
|
||||
guard totals.count >= 13 else {
|
||||
rollingReturnData = []
|
||||
return
|
||||
}
|
||||
|
||||
var returns: [(date: Date, value: Double)] = []
|
||||
for index in 12..<monthlyTotals.count {
|
||||
let current = monthlyTotals[index]
|
||||
let base = monthlyTotals[index - 12]
|
||||
for index in 12..<totals.count {
|
||||
let current = totals[index]
|
||||
let base = totals[index - 12]
|
||||
guard base.totalValue > 0 else { continue }
|
||||
let change = current.totalValue - base.totalValue
|
||||
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
|
||||
@@ -1034,51 +1062,60 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func calculateDrawdownData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
guard !sortedSnapshots.isEmpty else {
|
||||
let totals = monthlyTotals(from: snapshots)
|
||||
guard !totals.isEmpty else {
|
||||
drawdownData = []
|
||||
return
|
||||
}
|
||||
|
||||
var peak = sortedSnapshots.first!.decimalValue
|
||||
var peak = totals.first!.totalValue
|
||||
var data: [(date: Date, drawdown: Double)] = []
|
||||
|
||||
for snapshot in sortedSnapshots {
|
||||
let value = snapshot.decimalValue
|
||||
if value > peak {
|
||||
peak = value
|
||||
}
|
||||
|
||||
for point in totals {
|
||||
let value = point.totalValue
|
||||
if value > peak { peak = value }
|
||||
let drawdown = peak > 0
|
||||
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
|
||||
: 0
|
||||
|
||||
data.append((date: snapshot.date, drawdown: -drawdown))
|
||||
data.append((date: point.date, drawdown: -drawdown))
|
||||
}
|
||||
|
||||
drawdownData = data
|
||||
}
|
||||
|
||||
private func calculateVolatilityData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
guard sortedSnapshots.count >= 3 else {
|
||||
let totals = monthlyTotals(from: snapshots)
|
||||
guard totals.count >= 4 else {
|
||||
volatilityData = []
|
||||
return
|
||||
}
|
||||
|
||||
var data: [(date: Date, volatility: Double)] = []
|
||||
// Compute monthly return series from portfolio totals
|
||||
var monthlyReturns: [Double] = []
|
||||
var returnDates: [Date] = []
|
||||
for i in 1..<totals.count {
|
||||
let prev = totals[i - 1].totalValue
|
||||
let curr = totals[i].totalValue
|
||||
guard prev > 0 else { continue }
|
||||
let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100
|
||||
monthlyReturns.append(ret)
|
||||
returnDates.append(totals[i].date)
|
||||
}
|
||||
|
||||
let windowSize = 3
|
||||
guard monthlyReturns.count >= windowSize else {
|
||||
volatilityData = []
|
||||
return
|
||||
}
|
||||
|
||||
for i in windowSize..<sortedSnapshots.count {
|
||||
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
||||
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
||||
|
||||
let mean = values.reduce(0, +) / Double(values.count)
|
||||
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
||||
let stdDev = sqrt(variance)
|
||||
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
|
||||
|
||||
data.append((date: sortedSnapshots[i].date, volatility: volatility))
|
||||
// Rolling std dev of monthly returns (annualised)
|
||||
var data: [(date: Date, volatility: Double)] = []
|
||||
for i in (windowSize - 1)..<monthlyReturns.count {
|
||||
let window = Array(monthlyReturns[(i - windowSize + 1)...i])
|
||||
let mean = window.reduce(0, +) / Double(window.count)
|
||||
let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(max(1, window.count - 1))
|
||||
let stdDev = sqrt(max(0, variance))
|
||||
data.append((date: returnDates[i], volatility: stdDev))
|
||||
}
|
||||
|
||||
volatilityData = data
|
||||
@@ -1089,39 +1126,33 @@ class ChartsViewModel: ObservableObject {
|
||||
predictionData = []
|
||||
return
|
||||
}
|
||||
|
||||
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
|
||||
let totals = monthlyTotals(from: snapshots)
|
||||
let series = totals.map { (date: $0.date, value: $0.totalValue) }
|
||||
let result = predictionEngine.predict(series: series, monthsAhead: predictionMonthsAhead)
|
||||
predictionData = result.predictions
|
||||
}
|
||||
|
||||
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
|
||||
guard !snapshots.isEmpty else {
|
||||
private func calculateYearOverYearData(from _: [Snapshot]) {
|
||||
// Always use full history (not time-range filtered)
|
||||
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
|
||||
let allSnapshots = freemiumValidator.filterSnapshots(
|
||||
snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
)
|
||||
|
||||
let totals = monthlyTotals(from: allSnapshots)
|
||||
guard !totals.isEmpty else {
|
||||
yearOverYearData = []
|
||||
return
|
||||
}
|
||||
|
||||
// Build all monthly totals (no time range filter for YoY)
|
||||
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
|
||||
let allSnapshots: [Snapshot]
|
||||
if let cached = cachedSnapshots {
|
||||
allSnapshots = cached
|
||||
} else {
|
||||
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
// group by (year, month), take latest per source
|
||||
var byYearMonth: [Int: [Int: Decimal]] = [:]
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
|
||||
let sorted = allSnapshots.sorted { $0.date < $1.date }
|
||||
for snap in sorted {
|
||||
guard let sourceId = snap.source?.id else { continue }
|
||||
latestBySource[sourceId] = snap
|
||||
let year = calendar.component(.year, from: snap.date)
|
||||
let month = calendar.component(.month, from: snap.date) // 1-based
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
byYearMonth[year, default: [:]][month] = total
|
||||
// Group monthly totals by (year, month)
|
||||
var byYearMonth: [Int: [Int: Decimal]] = [:]
|
||||
for point in totals {
|
||||
let year = calendar.component(.year, from: point.date)
|
||||
let month = calendar.component(.month, from: point.date)
|
||||
byYearMonth[year, default: [:]][month] = point.totalValue
|
||||
}
|
||||
|
||||
let years = byYearMonth.keys.sorted()
|
||||
@@ -1130,25 +1161,70 @@ class ChartsViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward-fill within each year: carry previous month if no data
|
||||
yearOverYearData = years.map { year in
|
||||
var values = [Decimal](repeating: .zero, count: 12)
|
||||
var last: Decimal = .zero
|
||||
let nowYear = calendar.component(.year, from: Date())
|
||||
let nowMonth = calendar.component(.month, from: Date())
|
||||
|
||||
// For each year, compute cumulative % return from the first available month (baseline = 0%)
|
||||
yearOverYearData = years.map { year -> YearSeries in
|
||||
var values = [Double](repeating: Double.nan, count: 12)
|
||||
let yearData = byYearMonth[year] ?? [:]
|
||||
let sortedMonths = yearData.keys.sorted()
|
||||
guard let baselineMonth = sortedMonths.first,
|
||||
let baselineDecimal = yearData[baselineMonth] else {
|
||||
return YearSeries(id: year, year: year, values: values)
|
||||
}
|
||||
let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue
|
||||
guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) }
|
||||
|
||||
for monthIdx in 0..<12 {
|
||||
let month = monthIdx + 1
|
||||
if let v = byYearMonth[year]?[month] {
|
||||
last = v
|
||||
if let v = yearData[month] {
|
||||
let vDouble = NSDecimalNumber(decimal: v).doubleValue
|
||||
values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0
|
||||
}
|
||||
values[monthIdx] = last
|
||||
}
|
||||
return YearSeries(id: year, year: year, values: values)
|
||||
|
||||
// #146: forecast the year-end value for the current, still-incomplete year so the
|
||||
// "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12.
|
||||
var forecastEnd: Double? = nil
|
||||
if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 {
|
||||
let monthsAhead = 12 - lastDataMonth
|
||||
let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in
|
||||
guard let v = yearData[m],
|
||||
let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil }
|
||||
return (date: d, value: v)
|
||||
}
|
||||
if absSeries.count >= 3 {
|
||||
let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead)
|
||||
if let predDec = result.predictions.last?.predictedValue {
|
||||
let predDouble = NSDecimalNumber(decimal: predDec).doubleValue
|
||||
forecastEnd = ((predDouble - baseline) / baseline) * 100.0
|
||||
}
|
||||
}
|
||||
// Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December.
|
||||
if forecastEnd == nil,
|
||||
let firstIdx = (0..<12).first(where: { !values[$0].isNaN }),
|
||||
let lastIdx = (0..<12).last(where: { !values[$0].isNaN }),
|
||||
lastIdx > firstIdx {
|
||||
let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx)
|
||||
forecastEnd = monthlyRate * Double(11 - firstIdx)
|
||||
}
|
||||
}
|
||||
|
||||
return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd)
|
||||
}
|
||||
|
||||
// Default-select the last 2 available years
|
||||
let availableYears = Set(years)
|
||||
if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) {
|
||||
yoySelectedYears = Set(years.suffix(2))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison Chart Calculation
|
||||
|
||||
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let colorHexes = Self.sourceColorHexesPublic
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
@@ -1191,6 +1267,13 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
case .absolute:
|
||||
points = monthlyValues
|
||||
case .monthlyReturn:
|
||||
points = monthlyValues.enumerated().map { idx, item in
|
||||
guard idx > 0 else { return (date: item.date, value: 0.0) }
|
||||
let prev = monthlyValues[idx - 1].value
|
||||
let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0
|
||||
return (date: item.date, value: pct)
|
||||
}
|
||||
}
|
||||
|
||||
let colorHex = colorHexes[index % colorHexes.count]
|
||||
@@ -1215,7 +1298,7 @@ class ChartsViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let colorHexes = Self.sourceColorHexesPublic
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
@@ -1395,12 +1478,21 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
|
||||
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
|
||||
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
|
||||
// Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes,
|
||||
// por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one).
|
||||
// Cota superior exclusiva al inicio del mes siguiente → incluye todo el último mes (simétrico A/B).
|
||||
let rangeStart = start.startOfMonth
|
||||
let rangeEnd = end.startOfMonth.adding(months: 1)
|
||||
let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd }
|
||||
guard !periodSnapshots.isEmpty else { return nil }
|
||||
|
||||
// Group by month, compute portfolio total
|
||||
// Group by month, compute portfolio total.
|
||||
// Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango).
|
||||
// chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para
|
||||
// snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer
|
||||
// el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B).
|
||||
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
Calendar.current.dateComponents([.year, .month], from: snap.date)
|
||||
}
|
||||
var monthlyTotalsArr: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class DashboardViewModel: ObservableObject {
|
||||
@@ -646,6 +647,82 @@ class DashboardViewModel: ObservableObject {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
var insights: [PortfolioInsight] {
|
||||
var result: [PortfolioInsight] = []
|
||||
guard portfolioSummary.totalValue > 0 else { return result }
|
||||
let total = portfolioSummary.totalValue
|
||||
|
||||
// 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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
))
|
||||
}
|
||||
|
||||
// 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
|
||||
))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
Array(categoryMetrics.prefix(5))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
let mode: Mode
|
||||
let source: InvestmentSource
|
||||
|
||||
/// Contribution stored on the snapshot when editing started (nil in add mode or
|
||||
/// when the snapshot had no contribution). Used to detect whether the user changed
|
||||
/// the contribution and should be offered to propagate it to other snapshots.
|
||||
private(set) var originalContribution: Decimal?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -63,6 +68,7 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
if let contribution = snapshot.contribution {
|
||||
includeContribution = true
|
||||
contributionString = formatDecimalForInput(contribution.decimalValue)
|
||||
originalContribution = contribution.decimalValue
|
||||
}
|
||||
notes = snapshot.notes ?? ""
|
||||
}
|
||||
@@ -128,6 +134,12 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
return parseDecimal(trimmed)
|
||||
}
|
||||
|
||||
/// True when the current contribution differs from the value the snapshot had when
|
||||
/// editing started. Drives the "propagate to other snapshots" prompt.
|
||||
var contributionChanged: Bool {
|
||||
contribution != originalContribution
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
|
||||
return value.currencyString
|
||||
|
||||
Reference in New Issue
Block a user