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:
alexandrev-tibco
2026-07-23 19:51:26 +02:00
parent fc05dfe2c6
commit cbcb1da0ac
11 changed files with 175 additions and 79 deletions
@@ -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(