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:
alexandrev-tibco
2026-02-11 23:37:11 +01:00
parent daaca95913
commit 4761e2e5c8
19 changed files with 1118 additions and 90 deletions
+156 -46
View File
@@ -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]) {
@@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject {
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
}
func isAchieved(_ goal: Goal) -> Bool {
Self.isAchieved(progress: progress(for: goal))
}
static func isAchieved(progress: Double) -> Bool {
progress >= 0.999
}
static func urgencyLevel(
targetDate: Date?,
isBehind: Bool,
isAchieved: Bool,
referenceDate: Date = Date()
) -> GoalUrgencyLevel {
guard let targetDate else { return .normal }
guard !isAchieved else { return .normal }
guard isBehind else { return .normal }
let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay)
if daysUntilTarget < 0 {
return .critical
}
return .warning
}
func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId {
return sourceRepository.sources
@@ -288,3 +313,9 @@ struct GoalPaceStatus {
let isBehind: Bool
let statusText: String
}
enum GoalUrgencyLevel: Equatable {
case normal
case warning
case critical
}