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 lastTimeRange: TimeRange?
private var lastCategoryId: UUID? private var lastCategoryId: UUID?
private var lastSourceId: UUID? private var lastSourceId: UUID?
private var lastSourceIds: Set<UUID> = []
private var lastAccountId: UUID? private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true private var lastShowAllAccounts: Bool = true
private var lastBreakdown: BreakdownMode = .category private var lastBreakdown: BreakdownMode = .category
@@ -211,9 +212,11 @@ class ChartsViewModel: ObservableObject {
// This prevents multiple rapid updates when switching between views // This prevents multiple rapid updates when switching between views
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount) Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
.combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts) .combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
.combineLatest($selectedSourceIds)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) .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 } guard let self else { return }
let (combined, selectedSource, selectedBreakdown, showAll) = outer
let (chartType, category, timeRange, _) = combined let (chartType, category, timeRange, _) = combined
// Performance: Skip update if nothing meaningful changed // Performance: Skip update if nothing meaningful changed
@@ -222,6 +225,7 @@ class ChartsViewModel: ObservableObject {
self.lastTimeRange != timeRange || self.lastTimeRange != timeRange ||
self.lastCategoryId != category?.id || self.lastCategoryId != category?.id ||
self.lastSourceId != selectedSource?.id || self.lastSourceId != selectedSource?.id ||
self.lastSourceIds != sourceIds ||
self.lastAccountId != safeSelectedAccountId || self.lastAccountId != safeSelectedAccountId ||
self.lastShowAllAccounts != showAll || self.lastShowAllAccounts != showAll ||
self.lastBreakdown != selectedBreakdown self.lastBreakdown != selectedBreakdown
@@ -231,6 +235,7 @@ class ChartsViewModel: ObservableObject {
self.lastTimeRange = timeRange self.lastTimeRange = timeRange
self.lastCategoryId = category?.id self.lastCategoryId = category?.id
self.lastSourceId = selectedSource?.id self.lastSourceId = selectedSource?.id
self.lastSourceIds = sourceIds
self.lastAccountId = safeSelectedAccountId self.lastAccountId = safeSelectedAccountId
self.lastShowAllAccounts = showAll self.lastShowAllAccounts = showAll
self.lastBreakdown = selectedBreakdown self.lastBreakdown = selectedBreakdown
@@ -384,7 +389,7 @@ class ChartsViewModel: ObservableObject {
let filtered = allCategories.filter { categoriesWithData.contains($0.id) } let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
switch chartType { switch chartType {
case .evolution, .prediction: case .evolution, .prediction, .allocation, .performance:
return filtered return filtered
default: default:
return [] return []
@@ -421,6 +426,22 @@ class ChartsViewModel: ObservableObject {
return selected.id 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( private func categoriesForStackedChart(
sources: [InvestmentSource], sources: [InvestmentSource],
selectedCategory: Category? selectedCategory: Category?
@@ -484,13 +505,25 @@ class ChartsViewModel: ObservableObject {
return sampled 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 // MARK: - Chart Calculations
private func calculateEvolutionData(from snapshots: [Snapshot]) { private func calculateEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) chartMonth(for: snapshot.date)
return DateComponents(year: components.year, month: components.month)
} }
var series: [(date: Date, value: Decimal)] = [] var series: [(date: Date, value: Decimal)] = []
@@ -577,12 +610,13 @@ class ChartsViewModel: ObservableObject {
) )
}.sorted { $0.value > $1.value } }.sorted { $0.value > $1.value }
case .source: case .source:
let colorMap = sourceColorMap(for: sources)
allocationData = sources.compactMap { source in allocationData = sources.compactMap { source in
guard source.latestValue > 0 else { return nil } guard source.latestValue > 0 else { return nil }
return ( return (
category: source.name, category: source.name,
value: source.latestValue, value: source.latestValue,
color: source.category?.colorHex ?? "#6B7280" color: colorMap[source.id] ?? "#6B7280"
) )
}.sorted { $0.value > $1.value } }.sorted { $0.value > $1.value }
} }
@@ -591,7 +625,7 @@ class ChartsViewModel: ObservableObject {
private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) { private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in 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 { let sortedMonths = groupedByMonth.keys.sorted {
@@ -600,7 +634,9 @@ class ChartsViewModel: ObservableObject {
return d1 < d2 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 { for monthKey in sortedMonths {
guard let monthSnapshots = groupedByMonth[monthKey], guard let monthSnapshots = groupedByMonth[monthKey],
@@ -625,14 +661,31 @@ class ChartsViewModel: ObservableObject {
let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280" let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280"
let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex) let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex)
categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, 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 } let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value }
guard total > 0 else { continue } 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 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 [] } guard !sourceIds.isEmpty else { return [] }
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) chartMonth(for: snapshot.date)
return DateComponents(year: components.year, month: components.month)
} }
var completed: Set<DateComponents> = [] var completed: Set<DateComponents> = []
@@ -683,16 +735,11 @@ class ChartsViewModel: ObservableObject {
) )
return snapshots.filter { snapshot in return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth let monthDate = chartMonthStart(for: snapshot.date)
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
if monthDate <= lastCompleted { if monthDate <= lastCompleted {
return true return true
} }
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) let key = chartMonth(for: snapshot.date)
let key = DateComponents(year: components.year, month: components.month)
return completedMonthsAfter.contains(key) return completedMonthsAfter.contains(key)
} }
} }
@@ -742,6 +789,7 @@ class ChartsViewModel: ObservableObject {
) )
}.sorted { $0.cagr > $1.cagr } }.sorted { $0.cagr > $1.cagr }
case .source: case .source:
let colorMap = sourceColorMap(for: sources)
performanceData = sources.compactMap { source in performanceData = sources.compactMap { source in
guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil } guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
@@ -759,14 +807,14 @@ class ChartsViewModel: ObservableObject {
return ( return (
category: source.name, category: source.name,
cagr: cagr, cagr: cagr,
color: source.category?.colorHex ?? "#6B7280" color: colorMap[source.id] ?? "#6B7280"
) )
}.sorted { $0.cagr > $1.cagr } }.sorted { $0.cagr > $1.cagr }
} }
} }
private func calculateContributionsData(from snapshots: [Snapshot]) { 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 contributionsData = grouped.map { date, items in
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution } let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
return (date: date, amount: total) return (date: date, amount: total)
@@ -830,7 +878,7 @@ class ChartsViewModel: ObservableObject {
private func calculateCashflowData(from snapshots: [Snapshot]) { private func calculateCashflowData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots) 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 .mapValues { items in
items.reduce(Decimal.zero) { $0 + $1.decimalContribution } items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
} }
@@ -908,36 +956,25 @@ class ChartsViewModel: ObservableObject {
} }
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted() chartMonthStart(for: snapshot.date)
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] = [:] let months = groupedByMonth.keys.sorted()
guard !months.isEmpty else { return [] }
var latestBySource: [UUID: Snapshot] = [:]
var totals: [(date: Date, totalValue: Decimal)] = [] var totals: [(date: Date, totalValue: Decimal)] = []
for (index, month) in months.enumerated() { for month in months {
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture if let monthSnapshots = groupedByMonth[month] {
var total: Decimal = 0 for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) {
guard let sourceId = snapshot.source?.id else { continue }
for (sourceId, sourceSnapshots) in snapshotsBySource { latestBySource[sourceId] = snapshot
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
} }
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
totals.append((date: month, totalValue: total)) totals.append((date: month, totalValue: total))
} }
@@ -947,8 +984,7 @@ class ChartsViewModel: ObservableObject {
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) chartMonth(for: snapshot.date)
return DateComponents(year: components.year, month: components.month)
} }
var totals: [(date: Date, totalValue: Decimal)] = [] var totals: [(date: Date, totalValue: Decimal)] = []