1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,9 +85,11 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
@Published var selectedChartType: ChartType = .evolution
|
||||
@Published var selectedCategory: Category?
|
||||
@Published var selectedSource: InvestmentSource?
|
||||
@Published var selectedTimeRange: TimeRange = .year
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var selectedBreakdown: BreakdownMode = .category
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||
@@ -111,7 +113,8 @@ class ChartsViewModel: ObservableObject {
|
||||
case month = "1M"
|
||||
case quarter = "3M"
|
||||
case halfYear = "6M"
|
||||
case year = "1Y"
|
||||
case year = "12M"
|
||||
case yearToDate = "YTD"
|
||||
case all = "All"
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -122,9 +125,33 @@ class ChartsViewModel: ObservableObject {
|
||||
case .quarter: return 3
|
||||
case .halfYear: return 6
|
||||
case .year: return 12
|
||||
case .yearToDate: return nil
|
||||
case .all: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func startDate(referenceDate: Date = Date()) -> Date? {
|
||||
switch self {
|
||||
case .month, .quarter, .halfYear, .year:
|
||||
guard let months else { return nil }
|
||||
return referenceDate.adding(months: -months).startOfDay
|
||||
case .yearToDate:
|
||||
return referenceDate.startOfYear
|
||||
case .all:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BreakdownMode: String, CaseIterable, Identifiable {
|
||||
case category = "By Category"
|
||||
case source = "By Source"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool {
|
||||
breakdown == .category
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
@@ -147,8 +174,10 @@ class ChartsViewModel: ObservableObject {
|
||||
private var lastChartType: ChartType?
|
||||
private var lastTimeRange: TimeRange?
|
||||
private var lastCategoryId: UUID?
|
||||
private var lastSourceId: UUID?
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
private var lastBreakdown: BreakdownMode = .category
|
||||
private var cachedSnapshots: [Snapshot]?
|
||||
private var isUpdateInProgress = false
|
||||
|
||||
@@ -179,9 +208,9 @@ class ChartsViewModel: ObservableObject {
|
||||
// Performance: Combine all selection changes into a single debounced stream
|
||||
// This prevents multiple rapid updates when switching between views
|
||||
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
||||
.combineLatest($showAllAccounts)
|
||||
.combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] combined, showAll in
|
||||
.sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in
|
||||
guard let self else { return }
|
||||
let (chartType, category, timeRange, _) = combined
|
||||
|
||||
@@ -190,15 +219,19 @@ class ChartsViewModel: ObservableObject {
|
||||
let hasChanges = self.lastChartType != chartType ||
|
||||
self.lastTimeRange != timeRange ||
|
||||
self.lastCategoryId != category?.id ||
|
||||
self.lastSourceId != selectedSource?.id ||
|
||||
self.lastAccountId != safeSelectedAccountId ||
|
||||
self.lastShowAllAccounts != showAll
|
||||
self.lastShowAllAccounts != showAll ||
|
||||
self.lastBreakdown != selectedBreakdown
|
||||
|
||||
if hasChanges {
|
||||
self.lastChartType = chartType
|
||||
self.lastTimeRange = timeRange
|
||||
self.lastCategoryId = category?.id
|
||||
self.lastSourceId = selectedSource?.id
|
||||
self.lastAccountId = safeSelectedAccountId
|
||||
self.lastShowAllAccounts = showAll
|
||||
self.lastBreakdown = selectedBreakdown
|
||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||
}
|
||||
@@ -226,12 +259,25 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
selectedChartType = chartType
|
||||
let allowedRanges = availableTimeRanges(for: chartType)
|
||||
if !allowedRanges.contains(selectedTimeRange) {
|
||||
selectedTimeRange = allowedRanges.first ?? .year
|
||||
}
|
||||
FirebaseService.shared.logChartViewed(
|
||||
chartType: chartType.rawValue,
|
||||
isPremium: chartType.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
|
||||
switch chartType {
|
||||
case .evolution, .performance:
|
||||
return [.all, .yearToDate, .year, .quarter]
|
||||
default:
|
||||
return [.month, .quarter, .halfYear, .year, .all]
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
||||
// Performance: Prevent re-entrancy
|
||||
guard !isUpdateInProgress else { return }
|
||||
@@ -244,7 +290,9 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
if let category = category {
|
||||
if let selectedSource {
|
||||
sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) }
|
||||
} else if let category = category {
|
||||
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
||||
} else {
|
||||
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
@@ -266,6 +314,10 @@ class ChartsViewModel: ObservableObject {
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
if let cutoffDate = timeRange.startDate() {
|
||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
let completedSnapshots = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: snapshots
|
||||
@@ -281,10 +333,14 @@ class ChartsViewModel: ObservableObject {
|
||||
)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources)
|
||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||
case .performance:
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
calculatePerformanceData(
|
||||
for: sources,
|
||||
snapshotsBySource: completedSnapshotsBySource,
|
||||
breakdown: selectedBreakdown
|
||||
)
|
||||
case .contributions:
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .rollingReturn:
|
||||
@@ -306,6 +362,10 @@ class ChartsViewModel: ObservableObject {
|
||||
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
if let selected = selectedSource,
|
||||
!availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||
selectedSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
func availableCategories(
|
||||
@@ -324,6 +384,19 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func availableSources(
|
||||
for chartType: ChartType,
|
||||
sources: [InvestmentSource]? = nil
|
||||
) -> [InvestmentSource] {
|
||||
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
switch chartType {
|
||||
case .evolution, .allocation, .performance:
|
||||
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return true
|
||||
@@ -479,21 +552,33 @@ class ChartsViewModel: ObservableObject {
|
||||
categoryEvolutionData = points
|
||||
}
|
||||
|
||||
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
||||
let categories = categoryRepository.categories
|
||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) {
|
||||
switch breakdown {
|
||||
case .category:
|
||||
let categories = categoryRepository.categories
|
||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
allocationData = categories.compactMap { category in
|
||||
let categorySources = valuesByCategory[category.id] ?? []
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
guard categoryValue > 0 else { return nil }
|
||||
allocationData = categories.compactMap { category in
|
||||
let categorySources = valuesByCategory[category.id] ?? []
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
guard categoryValue > 0 else { return nil }
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
value: categoryValue,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
return (
|
||||
category: category.name,
|
||||
value: categoryValue,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
case .source:
|
||||
allocationData = sources.compactMap { source in
|
||||
guard source.latestValue > 0 else { return nil }
|
||||
return (
|
||||
category: source.name,
|
||||
value: source.latestValue,
|
||||
color: source.category?.colorHex ?? "#6B7280"
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
}
|
||||
}
|
||||
|
||||
private func completedMonthKeys(
|
||||
@@ -565,36 +650,61 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
private func calculatePerformanceData(
|
||||
for sources: [InvestmentSource],
|
||||
snapshotsBySource: [UUID: [Snapshot]]
|
||||
snapshotsBySource: [UUID: [Snapshot]],
|
||||
breakdown: BreakdownMode
|
||||
) {
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
switch breakdown {
|
||||
case .category:
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
performanceData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
performanceData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
case .source:
|
||||
performanceData = sources.compactMap { source in
|
||||
guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
|
||||
return (
|
||||
category: source.name,
|
||||
cagr: cagr,
|
||||
color: source.category?.colorHex ?? "#6B7280"
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
||||
|
||||
Reference in New Issue
Block a user