Feature: rellenar huecos entre snapshots en las gráficas (interpolación lineal)
Nueva utilidad ChartGapFill: para cada source, rellena los meses sin snapshot entre dos conocidos con interpolación lineal (Feb=100k, Jun=130k → Mar/Abr/May ~110/120/130k), arrastra el último valor tras el último snapshot y no aporta antes del primero. Solo afecta al pintado de gráficas — NO crea ni modifica snapshots (cero riesgo para la sync iCloud). Aplicado a las evolution del Dashboard (total + por categoría) y de Charts. Toggle en Settings "Smooth Gaps in Charts" (ON por defecto), localizado 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:
@@ -735,41 +735,46 @@ class ChartsViewModel: ObservableObject {
|
||||
// MARK: - Chart Calculations
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||
chartMonth(for: snapshot.date)
|
||||
// Ordered month grid + each month's index.
|
||||
let sortedMonthKeys = Set(snapshots.map { chartMonth(for: $0.date) }).sorted {
|
||||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
let monthCount = sortedMonthKeys.count
|
||||
guard monthCount > 0 else { evolutionData = []; return }
|
||||
var indexByKey: [DateComponents: Int] = [:]
|
||||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||||
|
||||
// Per source: latest value at each month index.
|
||||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let sourceId = snapshot.source?.id,
|
||||
let idx = indexByKey[chartMonth(for: snapshot.date)] else { continue }
|
||||
let existing = knownBySource[sourceId]?[idx]
|
||||
if existing == nil || snapshot.date > existing!.date {
|
||||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||||
}
|
||||
}
|
||||
|
||||
let sortedMonthKeys = groupedByMonth.keys.sorted {
|
||||
(Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast)
|
||||
// Fill each source's gaps (interpolated when enabled, else stepped
|
||||
// carry-forward), then sum per month. Display only — see ChartGapFill.
|
||||
let interpolate = ChartGapFill.isEnabled
|
||||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||||
for (_, known) in knownBySource {
|
||||
let dense = ChartGapFill.denseValues(
|
||||
known: known.mapValues { $0.value },
|
||||
monthCount: monthCount,
|
||||
interpolate: interpolate
|
||||
)
|
||||
for i in 0..<monthCount where dense[i] != nil {
|
||||
monthTotals[i] += dense[i]!
|
||||
}
|
||||
}
|
||||
|
||||
var series: [(date: Date, value: Decimal)] = []
|
||||
series.reserveCapacity(sortedMonthKeys.count)
|
||||
|
||||
// Forward-fill per source so a month's total includes every source's last
|
||||
// known value, not only the sources updated that month (which would make the
|
||||
// line dip and disagree with the portfolio total). See DashboardViewModel.
|
||||
var currentValueBySource: [UUID: Decimal] = [:]
|
||||
|
||||
for key in sortedMonthKeys {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in groupedByMonth[key] ?? [] {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
for (sourceId, snapshot) in latestBySource {
|
||||
currentValueBySource[sourceId] = snapshot.decimalValue
|
||||
}
|
||||
|
||||
let total = currentValueBySource.values.reduce(Decimal.zero, +)
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
series.append((date: date, value: total))
|
||||
series.reserveCapacity(monthCount)
|
||||
for i in 0..<monthCount {
|
||||
let date = Calendar.current.date(from: sortedMonthKeys[i]) ?? Date()
|
||||
series.append((date: date, value: monthTotals[i]))
|
||||
}
|
||||
|
||||
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
|
||||
|
||||
@@ -284,62 +284,72 @@ class DashboardViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||
let components = calendar.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
func monthKey(_ date: Date) -> DateComponents {
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
return DateComponents(year: c.year, month: c.month)
|
||||
}
|
||||
|
||||
let sortedMonthKeys = groupedByMonth.keys.sorted {
|
||||
// Ordered grid of every month that has data, and each month's index in it.
|
||||
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
|
||||
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
let monthCount = sortedMonthKeys.count
|
||||
guard monthCount > 0 else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
var indexByKey: [DateComponents: Int] = [:]
|
||||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||||
|
||||
// Per source: the latest snapshot value at each month index + its category.
|
||||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||||
var categoryBySource: [UUID: UUID] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let sourceId = snapshot.source?.id,
|
||||
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
|
||||
let existing = knownBySource[sourceId]?[idx]
|
||||
if existing == nil || snapshot.date > existing!.date {
|
||||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||||
}
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
categoryBySource[sourceId] = categoryId
|
||||
}
|
||||
}
|
||||
|
||||
// Fill each source's gaps (linear interpolation when enabled, else stepped
|
||||
// carry-forward), then sum per month. Interpolating per source means a
|
||||
// source last seen in Feb and next in Jun contributes a smooth ramp for
|
||||
// Mar/Apr/May instead of a flat line + jump. Chart display only — no data
|
||||
// is created (see ChartGapFill).
|
||||
let interpolate = ChartGapFill.isEnabled
|
||||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||||
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for (sourceId, known) in knownBySource {
|
||||
let dense = ChartGapFill.denseValues(
|
||||
known: known.mapValues { $0.value },
|
||||
monthCount: monthCount,
|
||||
interpolate: interpolate
|
||||
)
|
||||
let categoryId = categoryBySource[sourceId]
|
||||
for i in 0..<monthCount {
|
||||
guard let v = dense[i] else { continue }
|
||||
monthTotals[i] += v
|
||||
if let categoryId {
|
||||
monthCategory[i][categoryId, default: 0] += v
|
||||
categoryTotals[categoryId, default: 0] += v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(sortedMonthKeys.count)
|
||||
evolution.reserveCapacity(monthCount)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(sortedMonthKeys.count)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
// Forward-fill per source: carry each source's last known value into later
|
||||
// months where it has no new snapshot, so a month's total reflects EVERY
|
||||
// source's latest value (not only the ones updated that month). This keeps
|
||||
// the most-recent point equal to the dashboard total, which sums each
|
||||
// source's latest snapshot regardless of month.
|
||||
var currentValueBySource: [UUID: Decimal] = [:]
|
||||
var categoryBySource: [UUID: UUID] = [:]
|
||||
|
||||
for key in sortedMonthKeys {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in groupedByMonth[key] ?? [] {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
// Apply this month's updates on top of the carried-forward state.
|
||||
for (sourceId, snapshot) in latestBySource {
|
||||
currentValueBySource[sourceId] = snapshot.decimalValue
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
categoryBySource[sourceId] = categoryId
|
||||
}
|
||||
}
|
||||
|
||||
var total: Decimal = 0
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
for (sourceId, value) in currentValueBySource {
|
||||
total += value
|
||||
if let categoryId = categoryBySource[sourceId] {
|
||||
valuesByCategory[categoryId, default: 0] += value
|
||||
categoryTotals[categoryId, default: 0] += value
|
||||
}
|
||||
}
|
||||
|
||||
let date = calendar.date(from: key) ?? Date()
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
series.reserveCapacity(monthCount)
|
||||
for i in 0..<monthCount {
|
||||
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
|
||||
evolution.append((date: date, value: monthTotals[i]))
|
||||
series.append((date: date, valuesByCategory: monthCategory[i]))
|
||||
}
|
||||
|
||||
return EvolutionSummary(
|
||||
|
||||
Reference in New Issue
Block a user