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:
@@ -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 1–20 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()
|
||||
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)
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||
chartMonthStart(for: snapshot.date)
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
let months = groupedByMonth.keys.sorted()
|
||||
guard !months.isEmpty else { return [] }
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
total += latest?.decimalValue ?? 0
|
||||
}
|
||||
|
||||
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)] = []
|
||||
|
||||
Reference in New Issue
Block a user