cbcb1da0ac
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
66 lines
2.7 KiB
Swift
66 lines
2.7 KiB
Swift
import Foundation
|
|
|
|
/// Fills gaps between snapshots for chart display only — it never creates or
|
|
/// mutates Core Data objects. A source that has a snapshot in February and the
|
|
/// next in June leaves March/April/May empty; without filling, per-source and
|
|
/// category lines dip or break. This computes a value for every month in the
|
|
/// grid so the graphs read continuously.
|
|
///
|
|
/// - Internal gaps (between two known snapshots) are linearly interpolated when
|
|
/// `interpolate` is true, or carried forward (stepped) when false.
|
|
/// - After a source's last snapshot the value is carried forward (there is no
|
|
/// future point to interpolate towards) — matches the portfolio total.
|
|
/// - Before a source's first snapshot the value is `nil` (the source did not
|
|
/// exist yet, so it must not contribute 0 and drag the total down).
|
|
enum ChartGapFill {
|
|
|
|
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
|
|
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
|
|
static let defaultsKey = "smoothChartGaps"
|
|
|
|
static var isEnabled: Bool {
|
|
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
|
|
}
|
|
|
|
/// For a single source: `known` maps a month index (position in the ordered
|
|
/// month grid) to that month's value. Returns a value per month index, with
|
|
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
|
|
static func denseValues(
|
|
known: [Int: Decimal],
|
|
monthCount: Int,
|
|
interpolate: Bool
|
|
) -> [Decimal?] {
|
|
var result = [Decimal?](repeating: nil, count: monthCount)
|
|
let indices = known.keys.sorted()
|
|
guard let first = indices.first, let last = indices.last else { return result }
|
|
|
|
for i in indices { result[i] = known[i] }
|
|
|
|
if interpolate {
|
|
// Linear interpolation across each internal gap.
|
|
for k in 0..<(indices.count - 1) {
|
|
let a = indices[k]
|
|
let b = indices[k + 1]
|
|
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
|
|
for i in (a + 1)..<b {
|
|
let frac = Decimal(i - a) / Decimal(b - a)
|
|
result[i] = va + (vb - va) * frac
|
|
}
|
|
}
|
|
} else {
|
|
// Stepped carry-forward within internal gaps.
|
|
var lastVal = known[first] ?? .zero
|
|
for i in first...last {
|
|
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
|
|
}
|
|
}
|
|
|
|
// Carry the last known value forward past the final snapshot.
|
|
if let lastVal = known[last], last + 1 < monthCount {
|
|
for i in (last + 1)..<monthCount { result[i] = lastVal }
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|