Fix charts cutting off at Dec 2025: remove overly strict post-completion filter

The filter excluded snapshots with snapshot.date > completionDate for that month.
The bug: when a check-in for January is marked complete in February, the stored
completion date is backdated to Jan 31 (min(endOfMonth, now)), but batch update
snapshots were dated today (Feb 20). effectiveMonth maps Feb 20 → January, but
Feb 20 > Jan 31 triggered the exclusion, hiding all 2026 data from charts.

The check was redundant — monthDate <= lastCompleted already ensures only
completed months are included. Removing it also correctly handles the existing
snapshots already saved with a late date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-02-20 22:56:30 +01:00
parent c94974546f
commit b17d8866a4
@@ -177,6 +177,7 @@ class ChartsViewModel: ObservableObject {
private var lastTimeRange: TimeRange?
private var lastCategoryId: UUID?
private var lastSourceId: UUID?
private var lastSourceIds: Set<UUID> = []
private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true
private var lastBreakdown: BreakdownMode = .category
@@ -211,9 +212,11 @@ class ChartsViewModel: ObservableObject {
// This prevents multiple rapid updates when switching between views
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
.combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
.combineLatest($selectedSourceIds)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in
.sink { [weak self] outer, sourceIds in
guard let self else { return }
let (combined, selectedSource, selectedBreakdown, showAll) = outer
let (chartType, category, timeRange, _) = combined
// Performance: Skip update if nothing meaningful changed
@@ -222,6 +225,7 @@ class ChartsViewModel: ObservableObject {
self.lastTimeRange != timeRange ||
self.lastCategoryId != category?.id ||
self.lastSourceId != selectedSource?.id ||
self.lastSourceIds != sourceIds ||
self.lastAccountId != safeSelectedAccountId ||
self.lastShowAllAccounts != showAll ||
self.lastBreakdown != selectedBreakdown
@@ -231,6 +235,7 @@ class ChartsViewModel: ObservableObject {
self.lastTimeRange = timeRange
self.lastCategoryId = category?.id
self.lastSourceId = selectedSource?.id
self.lastSourceIds = sourceIds
self.lastAccountId = safeSelectedAccountId
self.lastShowAllAccounts = showAll
self.lastBreakdown = selectedBreakdown
@@ -384,7 +389,7 @@ class ChartsViewModel: ObservableObject {
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
switch chartType {
case .evolution, .prediction:
case .evolution, .prediction, .allocation, .performance:
return filtered
default:
return []
@@ -421,6 +426,22 @@ class ChartsViewModel: ObservableObject {
return selected.id
}
/// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
private static let sourceColorHexes: [String] = [
"#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
"#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
]
/// Assigns a unique color to each source based on its sorted position among all sources.
private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] {
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]
}
return map
}
private func categoriesForStackedChart(
sources: [InvestmentSource],
selectedCategory: Category?
@@ -484,13 +505,25 @@ class ChartsViewModel: ObservableObject {
return sampled
}
// MARK: - Effective Month Mapping
/// Maps a snapshot date to its effective check-in month.
/// Snapshots on days 120 are attributed to the previous month's check-in.
private func chartMonth(for snapshotDate: Date) -> DateComponents {
let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
return Calendar.current.dateComponents([.year, .month], from: effective)
}
private func chartMonthStart(for snapshotDate: Date) -> Date {
MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
}
// MARK: - Chart Calculations
private func calculateEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
chartMonth(for: snapshot.date)
}
var series: [(date: Date, value: Decimal)] = []
@@ -577,12 +610,13 @@ class ChartsViewModel: ObservableObject {
)
}.sorted { $0.value > $1.value }
case .source:
let colorMap = sourceColorMap(for: sources)
allocationData = sources.compactMap { source in
guard source.latestValue > 0 else { return nil }
return (
category: source.name,
value: source.latestValue,
color: source.category?.colorHex ?? "#6B7280"
color: colorMap[source.id] ?? "#6B7280"
)
}.sorted { $0.value > $1.value }
}
@@ -591,7 +625,7 @@ class ChartsViewModel: ObservableObject {
private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
Calendar.current.dateComponents([.year, .month], from: snapshot.date)
chartMonth(for: snapshot.date)
}
let sortedMonths = groupedByMonth.keys.sorted {
@@ -600,7 +634,9 @@ class ChartsViewModel: ObservableObject {
return d1 < d2
}
var result: [(date: Date, category: String, percentage: Double, color: String)] = []
// First pass: compute totals per category across all months for stable ordering
var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []
for monthKey in sortedMonths {
guard let monthSnapshots = groupedByMonth[monthKey],
@@ -625,14 +661,31 @@ class ChartsViewModel: ObservableObject {
let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280"
let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex)
categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex)
let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex)
globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex)
}
let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value }
guard total > 0 else { continue }
monthlyData.append((date: monthDate, categories: categoryTotals))
}
for (category, info) in categoryTotals.sorted(by: { $0.value.value > $1.value.value }) {
// Stable category order based on overall totals (largest first)
let stableCategoryOrder = globalCategoryTotals
.sorted { $0.value.total > $1.value.total }
.map { $0.key }
// Second pass: emit data points in stable order
var result: [(date: Date, category: String, percentage: Double, color: String)] = []
for month in monthlyData {
let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value }
guard total > 0 else { continue }
for category in stableCategoryOrder {
guard let info = month.categories[category] else { continue }
let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue
result.append((date: monthDate, category: category, percentage: percentage, color: info.color))
result.append((date: month.date, category: category, percentage: percentage, color: info.color))
}
}
@@ -648,8 +701,7 @@ class ChartsViewModel: ObservableObject {
guard !sourceIds.isEmpty else { return [] }
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
chartMonth(for: snapshot.date)
}
var completed: Set<DateComponents> = []
@@ -683,16 +735,11 @@ class ChartsViewModel: ObservableObject {
)
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
let monthDate = chartMonthStart(for: snapshot.date)
if monthDate <= lastCompleted {
return true
}
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
let key = DateComponents(year: components.year, month: components.month)
let key = chartMonth(for: snapshot.date)
return completedMonthsAfter.contains(key)
}
}
@@ -742,6 +789,7 @@ class ChartsViewModel: ObservableObject {
)
}.sorted { $0.cagr > $1.cagr }
case .source:
let colorMap = sourceColorMap(for: sources)
performanceData = sources.compactMap { source in
guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
@@ -759,14 +807,14 @@ class ChartsViewModel: ObservableObject {
return (
category: source.name,
cagr: cagr,
color: source.category?.colorHex ?? "#6B7280"
color: colorMap[source.id] ?? "#6B7280"
)
}.sorted { $0.cagr > $1.cagr }
}
}
private func calculateContributionsData(from snapshots: [Snapshot]) {
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
contributionsData = grouped.map { date, items in
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
return (date: date, amount: total)
@@ -830,7 +878,7 @@ class ChartsViewModel: ObservableObject {
private func calculateCashflowData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
let contributionsByMonth = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
.mapValues { items in
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
}
@@ -908,36 +956,25 @@ class ChartsViewModel: ObservableObject {
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
chartMonthStart(for: snapshot.date)
}
let months = groupedByMonth.keys.sorted()
guard !months.isEmpty else { return [] }
var snapshotsBySource: [UUID: [Snapshot]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(snapshot)
}
var indices: [UUID: Int] = [:]
var latestBySource: [UUID: Snapshot] = [:]
var totals: [(date: Date, totalValue: Decimal)] = []
for (index, month) in months.enumerated() {
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: Snapshot?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
total += latest?.decimalValue ?? 0
for month in months {
if let monthSnapshots = groupedByMonth[month] {
for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) {
guard let sourceId = snapshot.source?.id else { continue }
latestBySource[sourceId] = snapshot
}
}
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
totals.append((date: month, totalValue: total))
}
@@ -947,8 +984,7 @@ class ChartsViewModel: ObservableObject {
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
chartMonth(for: snapshot.date)
}
var totals: [(date: Date, totalValue: Decimal)] = []