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:
@@ -459,7 +459,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -688,7 +688,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -719,7 +719,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -743,7 +743,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -789,7 +789,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 33;
|
CURRENT_PROJECT_VERSION = 34;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class ChartsViewModel: ObservableObject {
|
|||||||
case volatility = "Volatility"
|
case volatility = "Volatility"
|
||||||
case prediction = "Prediction"
|
case prediction = "Prediction"
|
||||||
case yearOverYear = "Year vs Year"
|
case yearOverYear = "Year vs Year"
|
||||||
|
case comparison = "Compare"
|
||||||
|
case simulator = "What If"
|
||||||
|
case periodComparison = "Period vs Period"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -34,15 +37,18 @@ class ChartsViewModel: ObservableObject {
|
|||||||
case .volatility: return "waveform.path.ecg"
|
case .volatility: return "waveform.path.ecg"
|
||||||
case .prediction: return "wand.and.stars"
|
case .prediction: return "wand.and.stars"
|
||||||
case .yearOverYear: return "calendar"
|
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 {
|
var isPremium: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .evolution:
|
case .evolution, .comparison, .periodComparison:
|
||||||
return false
|
return false
|
||||||
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
||||||
.drawdown, .volatility, .prediction, .yearOverYear:
|
.drawdown, .volatility, .prediction, .yearOverYear, .simulator:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +77,12 @@ class ChartsViewModel: ObservableObject {
|
|||||||
return "View 12-month forecasts"
|
return "View 12-month forecasts"
|
||||||
case .yearOverYear:
|
case .yearOverYear:
|
||||||
return "Compare portfolio values across years"
|
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
|
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 isLoading = false
|
||||||
@Published var showingPaywall = false
|
@Published var showingPaywall = false
|
||||||
@Published private var predictionMonthsAhead = 12
|
@Published private var predictionMonthsAhead = 12
|
||||||
@@ -255,6 +317,51 @@ class ChartsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
// MARK: - Data Loading
|
||||||
@@ -291,8 +398,10 @@ class ChartsViewModel: ObservableObject {
|
|||||||
switch chartType {
|
switch chartType {
|
||||||
case .evolution, .performance:
|
case .evolution, .performance:
|
||||||
return [.all, .yearToDate, .year, .quarter]
|
return [.all, .yearToDate, .year, .quarter]
|
||||||
case .yearOverYear:
|
case .yearOverYear, .simulator, .periodComparison:
|
||||||
return [] // No time range filter for YoY — always uses all data
|
return [] // No time range filter for these charts
|
||||||
|
case .comparison:
|
||||||
|
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
|
||||||
default:
|
default:
|
||||||
return [.month, .quarter, .halfYear, .year, .all]
|
return [.month, .quarter, .halfYear, .year, .all]
|
||||||
}
|
}
|
||||||
@@ -383,6 +492,35 @@ class ChartsViewModel: ObservableObject {
|
|||||||
calculatePredictionData(from: completedSnapshots)
|
calculatePredictionData(from: completedSnapshots)
|
||||||
case .yearOverYear:
|
case .yearOverYear:
|
||||||
calculateYearOverYearData(from: completedSnapshots)
|
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,
|
if let selected = selectedCategory,
|
||||||
@@ -406,6 +544,8 @@ class ChartsViewModel: ObservableObject {
|
|||||||
switch chartType {
|
switch chartType {
|
||||||
case .evolution, .prediction, .allocation, .performance:
|
case .evolution, .prediction, .allocation, .performance:
|
||||||
return filtered
|
return filtered
|
||||||
|
case .comparison, .simulator, .periodComparison:
|
||||||
|
return []
|
||||||
default:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -419,6 +559,8 @@ class ChartsViewModel: ObservableObject {
|
|||||||
switch chartType {
|
switch chartType {
|
||||||
case .evolution, .allocation, .performance:
|
case .evolution, .allocation, .performance:
|
||||||
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
case .comparison, .simulator, .periodComparison:
|
||||||
|
return []
|
||||||
default:
|
default:
|
||||||
return []
|
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)] {
|
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||||
chartMonthStart(for: snapshot.date)
|
chartMonthStart(for: snapshot.date)
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct AllocationSimulatorView: View {
|
||||||
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Allocation Simulator")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if viewModel.simulatorSources.isEmpty {
|
||||||
|
emptyStateView
|
||||||
|
} else {
|
||||||
|
allocationCard
|
||||||
|
chartSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No sources available")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Allocation Card
|
||||||
|
|
||||||
|
private var allocationCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Adjust Allocation")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
totalAllocationBadge
|
||||||
|
resetButton
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(viewModel.simulatorSources.enumerated()), id: \.element.id) { index, source in
|
||||||
|
sourceSliderRow(index: index, source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalAllocationBadge: some View {
|
||||||
|
let total = viewModel.simulatorSources.reduce(0) { $0 + $1.simulatedPct }
|
||||||
|
let isNear100 = abs(total - 100) < 5
|
||||||
|
return Text(String(format: "%.0f%%", total))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
|
||||||
|
.foregroundColor(isNear100 ? .green : .orange)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resetButton: some View {
|
||||||
|
Button {
|
||||||
|
var updated = viewModel.simulatorSources
|
||||||
|
for i in updated.indices {
|
||||||
|
updated[i] = ChartsViewModel.SimulatorSource(
|
||||||
|
id: updated[i].id,
|
||||||
|
name: updated[i].name,
|
||||||
|
currentPct: updated[i].currentPct,
|
||||||
|
simulatedPct: updated[i].currentPct,
|
||||||
|
colorHex: updated[i].colorHex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel.simulatorSources = updated
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceSliderRow(index: Int, source: ChartsViewModel.SimulatorSource) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: source.colorHex) ?? .gray)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(source.name)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.0f%%", source.simulatedPct))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
Text(String(format: "(%.0f%%)", source.currentPct))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding<Double>(
|
||||||
|
get: { source.simulatedPct },
|
||||||
|
set: { newValue in
|
||||||
|
var updated = viewModel.simulatorSources
|
||||||
|
if index < updated.count {
|
||||||
|
updated[index] = ChartsViewModel.SimulatorSource(
|
||||||
|
id: source.id,
|
||||||
|
name: source.name,
|
||||||
|
currentPct: source.currentPct,
|
||||||
|
simulatedPct: newValue,
|
||||||
|
colorHex: source.colorHex
|
||||||
|
)
|
||||||
|
viewModel.simulatorSources = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0...100,
|
||||||
|
step: 1
|
||||||
|
)
|
||||||
|
.tint(Color(hex: source.colorHex) ?? .appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Section
|
||||||
|
|
||||||
|
private var chartSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
|
||||||
|
Text("Not enough data to simulate.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(height: 220)
|
||||||
|
} else {
|
||||||
|
simulationChart
|
||||||
|
chartLegend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simulationChart: some View {
|
||||||
|
Chart {
|
||||||
|
ForEach(viewModel.simulatorActualData, id: \.date) { item in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", item.date),
|
||||||
|
y: .value("Value", item.value),
|
||||||
|
series: .value("Series", "Actual")
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.blue)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.simulatorData, id: \.date) { item in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", item.date),
|
||||||
|
y: .value("Value", item.value),
|
||||||
|
series: .value("Series", "Simulated")
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 3]))
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||||
|
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { value in
|
||||||
|
AxisValueLabel {
|
||||||
|
if let d = value.as(Double.self) {
|
||||||
|
Text(String(format: "%.0f", d))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 220)
|
||||||
|
.drawingGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chartLegend: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
legendItem(color: .blue, label: "Actual", dashed: false)
|
||||||
|
legendItem(color: .orange, label: "Simulated", dashed: true)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func legendItem(color: Color, label: String, dashed: Bool) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if dashed {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 3)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 3)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 20, height: 3)
|
||||||
|
}
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var xAxisStride: Int {
|
||||||
|
let count = max(viewModel.simulatorActualData.count, viewModel.simulatorData.count)
|
||||||
|
switch count {
|
||||||
|
case ...6: return 1
|
||||||
|
case ...12: return 2
|
||||||
|
case ...24: return 3
|
||||||
|
default: return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,10 @@ struct ChartsContainerView: View {
|
|||||||
|
|
||||||
private var hasAnyFilter: Bool {
|
private var hasAnyFilter: Bool {
|
||||||
let chartType = viewModel.selectedChartType
|
let chartType = viewModel.selectedChartType
|
||||||
|
// These chart types manage their own controls internally, no external filter bar needed
|
||||||
|
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
|
||||||
|
return !viewModel.availableTimeRanges(for: chartType).isEmpty
|
||||||
|
}
|
||||||
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
|
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
|
||||||
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
||||||
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
||||||
@@ -498,6 +502,12 @@ struct ChartsContainerView: View {
|
|||||||
)
|
)
|
||||||
case .yearOverYear:
|
case .yearOverYear:
|
||||||
YearOverYearChartView(series: viewModel.yearOverYearData)
|
YearOverYearChartView(series: viewModel.yearOverYearData)
|
||||||
|
case .comparison:
|
||||||
|
ComparisonChartView(viewModel: viewModel)
|
||||||
|
case .simulator:
|
||||||
|
AllocationSimulatorView(viewModel: viewModel)
|
||||||
|
case .periodComparison:
|
||||||
|
PeriodComparisonChartView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct ComparisonChartView: View {
|
||||||
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
|
||||||
|
private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Compare Sources")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
sourceSelector
|
||||||
|
|
||||||
|
Picker("", selection: $viewModel.comparisonDisplayMode) {
|
||||||
|
ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in
|
||||||
|
Text(mode.rawValue).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
chartContent
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Selector
|
||||||
|
|
||||||
|
private var sourceSelector: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
|
||||||
|
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
|
||||||
|
let chipColor = palette[index % palette.count]
|
||||||
|
Button {
|
||||||
|
if isSelected {
|
||||||
|
viewModel.comparisonSelectedSourceIds.remove(source.id)
|
||||||
|
} else {
|
||||||
|
viewModel.comparisonSelectedSourceIds.insert(source.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(chipColor)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(source.name)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chartContent: some View {
|
||||||
|
if viewModel.comparisonSelectedSourceIds.isEmpty {
|
||||||
|
emptySelectionView
|
||||||
|
} else if viewModel.comparisonData.isEmpty {
|
||||||
|
noDataView
|
||||||
|
} else {
|
||||||
|
lineChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptySelectionView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "chart.xyaxis.line")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Select 2+ sources to compare")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noDataView: some View {
|
||||||
|
Text("No data available for selected sources in this period.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lineChart: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Chart {
|
||||||
|
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
|
||||||
|
let seriesColor = palette[index % palette.count]
|
||||||
|
ForEach(series.points, id: \.date) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", point.date),
|
||||||
|
y: .value("Value", point.value),
|
||||||
|
series: .value("Source", series.name)
|
||||||
|
)
|
||||||
|
.foregroundStyle(seriesColor)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||||
|
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { value in
|
||||||
|
AxisValueLabel {
|
||||||
|
if let d = value.as(Double.self) {
|
||||||
|
Text(yAxisLabel(for: d))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 260)
|
||||||
|
.drawingGroup()
|
||||||
|
|
||||||
|
legend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legend: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(palette[index % palette.count])
|
||||||
|
.frame(width: 20, height: 3)
|
||||||
|
Text(series.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var xAxisStride: Int {
|
||||||
|
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
|
||||||
|
switch maxPoints {
|
||||||
|
case ...6: return 1
|
||||||
|
case ...12: return 2
|
||||||
|
case ...24: return 3
|
||||||
|
default: return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func yAxisLabel(for value: Double) -> String {
|
||||||
|
switch viewModel.comparisonDisplayMode {
|
||||||
|
case .indexed:
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
case .returnPct:
|
||||||
|
return String(format: "%.1f%%", value)
|
||||||
|
case .absolute:
|
||||||
|
return Decimal(value).shortCurrencyString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct PeriodComparisonChartView: View {
|
||||||
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Period vs Period")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
periodPickersSection
|
||||||
|
chartContent
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Period Pickers
|
||||||
|
|
||||||
|
private var periodPickersSection: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
periodRow(
|
||||||
|
label: "Period A",
|
||||||
|
color: Color(hex: "#3478F6") ?? .blue,
|
||||||
|
start: $viewModel.periodAStart,
|
||||||
|
end: $viewModel.periodAEnd
|
||||||
|
)
|
||||||
|
periodRow(
|
||||||
|
label: "Period B",
|
||||||
|
color: Color(hex: "#FF9500") ?? .orange,
|
||||||
|
start: $viewModel.periodBStart,
|
||||||
|
end: $viewModel.periodBEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func periodRow(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
start: Binding<Date>,
|
||||||
|
end: Binding<Date>
|
||||||
|
) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.frame(width: 58, alignment: .leading)
|
||||||
|
|
||||||
|
DatePicker(
|
||||||
|
"",
|
||||||
|
selection: Binding<Date>(
|
||||||
|
get: { firstOfMonth(start.wrappedValue) },
|
||||||
|
set: { start.wrappedValue = firstOfMonth($0) }
|
||||||
|
),
|
||||||
|
displayedComponents: [.date]
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
Text("–")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
DatePicker(
|
||||||
|
"",
|
||||||
|
selection: Binding<Date>(
|
||||||
|
get: { firstOfMonth(end.wrappedValue) },
|
||||||
|
set: { end.wrappedValue = firstOfMonth($0) }
|
||||||
|
),
|
||||||
|
displayedComponents: [.date]
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chartContent: some View {
|
||||||
|
if viewModel.periodComparisonData.isEmpty {
|
||||||
|
emptyView
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
periodChart
|
||||||
|
legend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No data available for the selected periods.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flat data for Chart
|
||||||
|
|
||||||
|
private struct PeriodPoint: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let seriesId: String
|
||||||
|
let seriesLabel: String
|
||||||
|
let colorHex: String
|
||||||
|
let monthOffset: Int
|
||||||
|
let returnPct: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private var flatPoints: [PeriodPoint] {
|
||||||
|
var result: [PeriodPoint] = []
|
||||||
|
for series in viewModel.periodComparisonData {
|
||||||
|
for point in series.points {
|
||||||
|
result.append(PeriodPoint(
|
||||||
|
id: "\(series.id)-\(point.monthOffset)",
|
||||||
|
seriesId: series.id,
|
||||||
|
seriesLabel: series.label,
|
||||||
|
colorHex: series.colorHex,
|
||||||
|
monthOffset: point.monthOffset,
|
||||||
|
returnPct: point.returnPct
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var periodChart: some View {
|
||||||
|
let points = flatPoints
|
||||||
|
let seriesIds = viewModel.periodComparisonData.map { $0.id }
|
||||||
|
let seriesColors: [Color] = viewModel.periodComparisonData.map {
|
||||||
|
Color(hex: $0.colorHex) ?? .gray
|
||||||
|
}
|
||||||
|
|
||||||
|
return Chart(points) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Month", point.monthOffset),
|
||||||
|
y: .value("Return", point.returnPct),
|
||||||
|
series: .value("Period", point.seriesLabel)
|
||||||
|
)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||||
|
|
||||||
|
PointMark(
|
||||||
|
x: .value("Month", point.monthOffset),
|
||||||
|
y: .value("Return", point.returnPct)
|
||||||
|
)
|
||||||
|
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||||
|
.symbolSize(20)
|
||||||
|
}
|
||||||
|
.chartForegroundStyleScale(
|
||||||
|
domain: viewModel.periodComparisonData.map { $0.label },
|
||||||
|
range: seriesColors
|
||||||
|
)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: xAxisValues) { value in
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||||
|
AxisValueLabel {
|
||||||
|
if let idx = value.as(Int.self) {
|
||||||
|
Text("M\(idx + 1)")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { value in
|
||||||
|
AxisValueLabel {
|
||||||
|
if let d = value.as(Double.self) {
|
||||||
|
Text(String(format: "%.1f%%", d))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 260)
|
||||||
|
.drawingGroup()
|
||||||
|
.onChange(of: seriesIds) { _, _ in } // silence unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legend: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(viewModel.periodComparisonData) { series in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color(hex: series.colorHex) ?? .gray)
|
||||||
|
.frame(width: 20, height: 3)
|
||||||
|
Text(series.label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var xAxisValues: [Int] {
|
||||||
|
let maxOffset = viewModel.periodComparisonData
|
||||||
|
.flatMap { $0.points }
|
||||||
|
.map { $0.monthOffset }
|
||||||
|
.max() ?? 0
|
||||||
|
return Array(0...maxOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func firstOfMonth(_ date: Date) -> Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var components = calendar.dateComponents([.year, .month], from: date)
|
||||||
|
components.day = 1
|
||||||
|
return calendar.date(from: components) ?? date
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user