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 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 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
|
// 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)] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user