Add 3 interactive chart types: Compare, What If, Period vs Period (build 34)
- Compare (free): multi-source overlay chart, Base 100 / Return % / Value modes, chip selector, reactive to time range filter - What If (premium): allocation simulator with sliders per source, dual-line chart actual vs simulated indexed to 100, orange warning when weights ≠ 100% - Period vs Period (free): date-picker-driven comparison of any two custom periods, return % normalized from month 1 = 0%, color-coded legend with period labels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ class ChartsViewModel: ObservableObject {
|
||||
case volatility = "Volatility"
|
||||
case prediction = "Prediction"
|
||||
case yearOverYear = "Year vs Year"
|
||||
case comparison = "Compare"
|
||||
case simulator = "What If"
|
||||
case periodComparison = "Period vs Period"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -34,15 +37,18 @@ class ChartsViewModel: ObservableObject {
|
||||
case .volatility: return "waveform.path.ecg"
|
||||
case .prediction: return "wand.and.stars"
|
||||
case .yearOverYear: return "calendar"
|
||||
case .comparison: return "chart.xyaxis.line"
|
||||
case .simulator: return "slider.horizontal.3"
|
||||
case .periodComparison: return "calendar.badge.clock"
|
||||
}
|
||||
}
|
||||
|
||||
var isPremium: Bool {
|
||||
switch self {
|
||||
case .evolution:
|
||||
case .evolution, .comparison, .periodComparison:
|
||||
return false
|
||||
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
||||
.drawdown, .volatility, .prediction, .yearOverYear:
|
||||
.drawdown, .volatility, .prediction, .yearOverYear, .simulator:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,12 @@ class ChartsViewModel: ObservableObject {
|
||||
return "View 12-month forecasts"
|
||||
case .yearOverYear:
|
||||
return "Compare portfolio values across years"
|
||||
case .comparison:
|
||||
return "Compare sources side by side"
|
||||
case .simulator:
|
||||
return "Simulate portfolio reallocation"
|
||||
case .periodComparison:
|
||||
return "Compare two time periods side by side"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +128,56 @@ class ChartsViewModel: ObservableObject {
|
||||
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
|
||||
}
|
||||
|
||||
// MARK: - Comparison chart data models
|
||||
|
||||
enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
|
||||
case indexed = "Base 100"
|
||||
case returnPct = "Return %"
|
||||
case absolute = "Value"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct ComparisonSeries: Identifiable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let colorHex: String
|
||||
let points: [(date: Date, value: Double)]
|
||||
}
|
||||
|
||||
@Published var comparisonData: [ComparisonSeries] = []
|
||||
@Published var comparisonSelectedSourceIds: Set<UUID> = []
|
||||
@Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed
|
||||
@Published var comparisonAvailableSources: [InvestmentSource] = []
|
||||
|
||||
// MARK: - Simulator chart data models
|
||||
|
||||
struct SimulatorSource: Identifiable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let currentPct: Double
|
||||
var simulatedPct: Double
|
||||
let colorHex: String
|
||||
}
|
||||
|
||||
@Published var simulatorSources: [SimulatorSource] = []
|
||||
@Published var simulatorActualData: [(date: Date, value: Double)] = []
|
||||
@Published var simulatorData: [(date: Date, value: Double)] = []
|
||||
|
||||
// MARK: - Period comparison data models
|
||||
|
||||
struct PeriodSeries: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let colorHex: String // e.g. "#0000FF" for blue, "#FF8000" for orange
|
||||
let points: [(monthOffset: Int, returnPct: Double)]
|
||||
}
|
||||
|
||||
@Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date()
|
||||
@Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||
@Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date()
|
||||
@Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date()
|
||||
@Published var periodComparisonData: [PeriodSeries] = []
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@Published private var predictionMonthsAhead = 12
|
||||
@@ -255,6 +317,51 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Comparison chart: react to source selection and display mode changes
|
||||
Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode)
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.selectedChartType == .comparison else { return }
|
||||
self.isLoading = true
|
||||
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
var snapshots: [Snapshot]
|
||||
if let cached = self.cachedSnapshots {
|
||||
snapshots = cached
|
||||
} else {
|
||||
snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
|
||||
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
|
||||
}
|
||||
var filtered = snapshots
|
||||
if let cutoff = self.selectedTimeRange.startDate() {
|
||||
filtered = snapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
|
||||
self.isLoading = false
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Simulator chart: react to slider changes
|
||||
$simulatorSources
|
||||
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] sources in
|
||||
guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return }
|
||||
self.recalculateSimulatedLine(sources: sources)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Period comparison: react to date picker changes
|
||||
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.selectedChartType == .periodComparison else { return }
|
||||
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
|
||||
self.calculatePeriodComparisonData(allSnapshots: snapshots)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
@@ -291,8 +398,10 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .performance:
|
||||
return [.all, .yearToDate, .year, .quarter]
|
||||
case .yearOverYear:
|
||||
return [] // No time range filter for YoY — always uses all data
|
||||
case .yearOverYear, .simulator, .periodComparison:
|
||||
return [] // No time range filter for these charts
|
||||
case .comparison:
|
||||
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
|
||||
default:
|
||||
return [.month, .quarter, .halfYear, .year, .all]
|
||||
}
|
||||
@@ -383,6 +492,35 @@ class ChartsViewModel: ObservableObject {
|
||||
calculatePredictionData(from: completedSnapshots)
|
||||
case .yearOverYear:
|
||||
calculateYearOverYearData(from: completedSnapshots)
|
||||
case .comparison:
|
||||
// Use ALL sources, not filtered by selectedSource/category
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
comparisonAvailableSources = allSources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
var allSnapshots: [Snapshot]
|
||||
if let cached = cachedSnapshots {
|
||||
allSnapshots = cached
|
||||
} else {
|
||||
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||
}
|
||||
var filteredForComparison = allSnapshots
|
||||
if let cutoff = timeRange.startDate() {
|
||||
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
|
||||
case .simulator:
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots)
|
||||
case .periodComparison:
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
calculatePeriodComparisonData(allSnapshots: allSnapshots)
|
||||
}
|
||||
|
||||
if let selected = selectedCategory,
|
||||
@@ -406,6 +544,8 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .prediction, .allocation, .performance:
|
||||
return filtered
|
||||
case .comparison, .simulator, .periodComparison:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -419,6 +559,8 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .allocation, .performance:
|
||||
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
case .comparison, .simulator, .periodComparison:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -1003,6 +1145,305 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison Chart Calculation
|
||||
|
||||
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
|
||||
var result: [ComparisonSeries] = []
|
||||
|
||||
for (index, source) in sortedSources.enumerated() {
|
||||
guard let sourceId = source.id as UUID?,
|
||||
comparisonSelectedSourceIds.contains(sourceId) else { continue }
|
||||
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
|
||||
// Group by month, take latest per month
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthlyValues: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthlyValues.sort { $0.date < $1.date }
|
||||
guard !monthlyValues.isEmpty else { continue }
|
||||
|
||||
let firstValue = monthlyValues[0].value
|
||||
let points: [(date: Date, value: Double)]
|
||||
switch comparisonDisplayMode {
|
||||
case .indexed:
|
||||
points = monthlyValues.map { item in
|
||||
let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0
|
||||
return (date: item.date, value: v)
|
||||
}
|
||||
case .returnPct:
|
||||
points = monthlyValues.map { item in
|
||||
let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0
|
||||
return (date: item.date, value: v)
|
||||
}
|
||||
case .absolute:
|
||||
points = monthlyValues
|
||||
}
|
||||
|
||||
let colorHex = colorHexes[index % colorHexes.count]
|
||||
result.append(ComparisonSeries(
|
||||
id: sourceId,
|
||||
name: source.name,
|
||||
colorHex: colorHex,
|
||||
points: points
|
||||
))
|
||||
}
|
||||
|
||||
comparisonData = result
|
||||
}
|
||||
|
||||
// MARK: - Simulator Chart Calculation
|
||||
|
||||
func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||||
guard !sources.isEmpty else {
|
||||
simulatorSources = []
|
||||
simulatorActualData = []
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
|
||||
// Build monthly totals per source
|
||||
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
|
||||
for source in sortedSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthly: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthly.sort { $0.date < $1.date }
|
||||
sourceMonthlyValues[sourceId] = monthly
|
||||
}
|
||||
|
||||
// Gather all unique months across sources
|
||||
var allMonthsSet = Set<Date>()
|
||||
for monthly in sourceMonthlyValues.values {
|
||||
monthly.forEach { allMonthsSet.insert($0.date) }
|
||||
}
|
||||
let allMonths = allMonthsSet.sorted()
|
||||
guard !allMonths.isEmpty else {
|
||||
simulatorActualData = []
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
// Build actual total portfolio per month (forward-fill per source)
|
||||
var lastValueBySource: [UUID: Double] = [:]
|
||||
var actualTotals: [(date: Date, value: Double)] = []
|
||||
for month in allMonths {
|
||||
for source in sortedSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
if let monthly = sourceMonthlyValues[sourceId],
|
||||
let entry = monthly.first(where: { $0.date == month }) {
|
||||
lastValueBySource[sourceId] = entry.value
|
||||
}
|
||||
}
|
||||
let total = lastValueBySource.values.reduce(0, +)
|
||||
actualTotals.append((date: month, value: total))
|
||||
}
|
||||
|
||||
// Normalize actual to Base 100
|
||||
let firstTotal = actualTotals.first?.value ?? 1
|
||||
simulatorActualData = actualTotals.map { item in
|
||||
(date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0)
|
||||
}
|
||||
|
||||
// Compute current weights
|
||||
let latestTotalValue = actualTotals.last?.value ?? 0
|
||||
var simulatorNew: [SimulatorSource] = []
|
||||
for (index, source) in sortedSources.enumerated() {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0
|
||||
let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0
|
||||
// Preserve existing simulatedPct if source already exists in simulatorSources
|
||||
let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct
|
||||
simulatorNew.append(SimulatorSource(
|
||||
id: sourceId,
|
||||
name: source.name,
|
||||
currentPct: currentPct,
|
||||
simulatedPct: existingPct,
|
||||
colorHex: colorHexes[index % colorHexes.count]
|
||||
))
|
||||
}
|
||||
|
||||
// Only update if sources changed (avoid resetting sliders on normal refresh)
|
||||
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
|
||||
simulatorSources = simulatorNew
|
||||
} else {
|
||||
// Update currentPct but preserve simulatedPct
|
||||
simulatorSources = simulatorNew
|
||||
}
|
||||
|
||||
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
||||
}
|
||||
|
||||
func recalculateSimulatedLine(sources: [SimulatorSource]) {
|
||||
// Re-fetch source monthly values for recalculation from current allSources
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
|
||||
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
|
||||
for source in allSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthly: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthly.sort { $0.date < $1.date }
|
||||
sourceMonthlyValues[sourceId] = monthly
|
||||
}
|
||||
|
||||
var allMonthsSet = Set<Date>()
|
||||
for monthly in sourceMonthlyValues.values {
|
||||
monthly.forEach { allMonthsSet.insert($0.date) }
|
||||
}
|
||||
let allMonths = allMonthsSet.sorted()
|
||||
recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
||||
}
|
||||
|
||||
private func recalculateSimulatedLine(
|
||||
sources: [SimulatorSource],
|
||||
sourceMonthlyValues: [UUID: [(date: Date, value: Double)]],
|
||||
allMonths: [Date]
|
||||
) {
|
||||
guard !allMonths.isEmpty, !sources.isEmpty else {
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize weights
|
||||
let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct }
|
||||
guard totalSimulated > 0 else {
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
let normalizedWeights: [UUID: Double] = Dictionary(
|
||||
uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) }
|
||||
)
|
||||
|
||||
// Build per-source monthly return series
|
||||
var sourceReturns: [UUID: [Date: Double]] = [:]
|
||||
for source in sources {
|
||||
guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue }
|
||||
var returns: [Date: Double] = [:]
|
||||
for i in 1..<monthly.count {
|
||||
let prev = monthly[i - 1].value
|
||||
let curr = monthly[i].value
|
||||
if prev > 0 {
|
||||
returns[monthly[i].date] = (curr - prev) / prev
|
||||
}
|
||||
}
|
||||
sourceReturns[source.id] = returns
|
||||
}
|
||||
|
||||
// Simulate portfolio
|
||||
var simulated = 100.0
|
||||
var simulatedSeries: [(date: Date, value: Double)] = []
|
||||
if let firstMonth = allMonths.first {
|
||||
simulatedSeries.append((date: firstMonth, value: simulated))
|
||||
}
|
||||
for i in 1..<allMonths.count {
|
||||
let month = allMonths[i]
|
||||
var portfolioReturn = 0.0
|
||||
for source in sources {
|
||||
let weight = normalizedWeights[source.id] ?? 0
|
||||
let ret = sourceReturns[source.id]?[month] ?? 0
|
||||
portfolioReturn += weight * ret
|
||||
}
|
||||
simulated *= (1 + portfolioReturn)
|
||||
simulatedSeries.append((date: month, value: simulated))
|
||||
}
|
||||
|
||||
simulatorData = simulatedSeries
|
||||
}
|
||||
|
||||
// MARK: - Period Comparison Chart Calculation
|
||||
|
||||
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
|
||||
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
|
||||
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
|
||||
guard !periodSnapshots.isEmpty else { return nil }
|
||||
|
||||
// Group by month, compute portfolio total
|
||||
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthlyTotalsArr: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snap in snaps {
|
||||
guard let sourceId = snap.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snap.date > existing.date { latestBySource[sourceId] = snap }
|
||||
} else {
|
||||
latestBySource[sourceId] = snap
|
||||
}
|
||||
}
|
||||
let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthlyTotalsArr.append((date: date, value: total))
|
||||
}
|
||||
monthlyTotalsArr.sort { $0.date < $1.date }
|
||||
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
|
||||
|
||||
let points = monthlyTotalsArr.enumerated().map { index, item in
|
||||
(monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0)
|
||||
}
|
||||
return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points)
|
||||
}
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "MMM yyyy"
|
||||
|
||||
let labelA = "\(dateFormatter.string(from: periodAStart))–\(dateFormatter.string(from: periodAEnd))"
|
||||
let labelB = "\(dateFormatter.string(from: periodBStart))–\(dateFormatter.string(from: periodBEnd))"
|
||||
|
||||
var result: [PeriodSeries] = []
|
||||
if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd,
|
||||
label: labelA, colorHex: "#3478F6", id: "A") {
|
||||
result.append(seriesA)
|
||||
}
|
||||
if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd,
|
||||
label: labelB, colorHex: "#FF9500", id: "B") {
|
||||
result.append(seriesB)
|
||||
}
|
||||
periodComparisonData = result
|
||||
}
|
||||
|
||||
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||
chartMonthStart(for: snapshot.date)
|
||||
|
||||
Reference in New Issue
Block a user