diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index a4b8231..9240b9c 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 59; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal/Views/Charts/ChartValueLabels.swift b/PortfolioJournal/Views/Charts/ChartValueLabels.swift index 5e23ca5..cd09921 100644 --- a/PortfolioJournal/Views/Charts/ChartValueLabels.swift +++ b/PortfolioJournal/Views/Charts/ChartValueLabels.swift @@ -11,6 +11,28 @@ enum ChartLabels { /// A series with at most this many points shows its value labels permanently. static let alwaysShowThreshold = 12 + /// Max permanent labels to render before thinning — fewer on the narrow + /// iPhone screen where crammed labels overlap and become unreadable. + static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 } + + /// Stride between labelled points so at most `maxLabels` show. + static func stride(count: Int, compact: Bool) -> Int { + let maxN = maxLabels(compact: compact) + guard count > maxN else { return 1 } + return Int(ceil(Double(count) / Double(maxN))) + } + + /// Whether point `index` should carry a permanent label: always the first + /// and last (the endpoints matter most), plus every `stride`-th in between. + static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool { + guard count > 0 else { return false } + if index == 0 || index == count - 1 { return true } + let s = stride(count: count, compact: compact) + // Avoid a label landing right next to the forced last one. + if index >= count - 1 - (s / 2) { return false } + return index % s == 0 + } + static func compactCurrency(_ value: Decimal) -> String { value.compactCurrencyString } diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 0a4bd45..abfb688 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -492,7 +492,7 @@ struct ChartsContainerView: View { } Spacer() if viewModel.selectedChartType == .performance { - performanceSlider + performancePeriodPicker .frame(maxWidth: 360) } else if !ranges.isEmpty { Picker("Period", selection: $viewModel.selectedTimeRange) { @@ -508,22 +508,37 @@ struct ChartsContainerView: View { .padding(.horizontal, 4) } - private var performanceSlider: some View { - HStack(spacing: 10) { - Text("Period: \(periodLabel(viewModel.performancePeriodMonths))") - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - .frame(width: 92, alignment: .leading) - Slider( - value: Binding( - get: { Double(viewModel.performancePeriodMonths) }, - set: { viewModel.performancePeriodMonths = Int($0) } - ), - in: 1...60, - step: 1 - ) - .tint(.appPrimary) + /// Months of history actually available (distinct aggregated months). The + /// Performance period options never exceed this, so the control can't ask + /// for a window with no data. + private var availableHistoryMonths: Int { + max(1, viewModel.evolutionData.count) + } + + /// Discrete period options (in months) capped to available data — mirrors + /// the segmented period control used by every other chart for consistency. + private var performancePeriodOptions: [(label: String, months: Int)] { + let candidates = [(3, "3M"), (6, "6M"), (12, "12M"), (24, "2Y")] + var opts = candidates.filter { $0.0 < availableHistoryMonths }.map { ($0.1, $0.0) } + opts.append(("All", availableHistoryMonths)) // full history + return opts.map { (label: $0.0, months: $0.1) } + } + + private var performancePeriodPicker: some View { + Picker("Period", selection: Binding( + get: { + // Snap to the closest available option. + let m = viewModel.performancePeriodMonths + return performancePeriodOptions.min(by: { abs($0.months - m) < abs($1.months - m) })?.months + ?? availableHistoryMonths + }, + set: { viewModel.performancePeriodMonths = min($0, availableHistoryMonths) } + )) { + ForEach(performancePeriodOptions, id: \.months) { opt in + Text(opt.label).tag(opt.months) + } } + .pickerStyle(.segmented) } private var shareButton: some View { @@ -605,10 +620,7 @@ struct ChartsContainerView: View { private var iPhonePeriodControl: some View { let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType) if viewModel.selectedChartType == .performance { - performanceSlider - .padding(12) - .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + performancePeriodPicker } else if !ranges.isEmpty { Picker("Period", selection: $viewModel.selectedTimeRange) { ForEach(ranges) { range in @@ -815,6 +827,9 @@ struct EvolutionChartView: View { let categoryData: [CategoryEvolutionPoint] let goals: [Goal] @Environment(\.chartImageExport) private var chartImageExport + @Environment(\.horizontalSizeClass) private var labelsSizeClass + + private var labelsCompact: Bool { labelsSizeClass != .regular } @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @@ -1033,7 +1048,8 @@ struct EvolutionChartView: View { private var chartMarks: some ChartContent { switch chartMode { case .total: - ForEach(data, id: \.date) { item in + ForEach(Array(data.enumerated()), id: \.element.date) { pair in + let item = pair.element LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) @@ -1046,9 +1062,9 @@ struct EvolutionChartView: View { y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) - .symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30) + .symbolSize(30) .annotation(position: .top, spacing: 3) { - if data.count <= ChartLabels.alwaysShowThreshold { + if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: item.value.compactCurrencyString) } } @@ -1208,8 +1224,9 @@ struct RollingReturnChartView: View { let data: [(date: Date, value: Double)] @State private var zoom = ChartZoomModel() @State private var selectedDate: Date? + @Environment(\.horizontalSizeClass) private var labelsSizeClass - private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var labelsCompact: Bool { labelsSizeClass != .regular } private var selectedPoint: (date: Date, value: Double)? { guard let selectedDate else { return nil } return data.min(by: { @@ -1228,7 +1245,8 @@ struct RollingReturnChartView: View { .frame(height: 260) } else { Chart { - ForEach(data, id: \.date) { item in + ForEach(Array(data.enumerated()), id: \.element.date) { pair in + let item = pair.element LineMark( x: .value("Month", item.date), y: .value("Return", item.value) @@ -1241,9 +1259,9 @@ struct RollingReturnChartView: View { y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) - .symbolSize(showAllLabels ? 36 : 24) + .symbolSize(24) .annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) { - if showAllLabels { + if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: ChartLabels.percent(item.value)) } } diff --git a/PortfolioJournal/Views/Charts/ComparisonChartView.swift b/PortfolioJournal/Views/Charts/ComparisonChartView.swift index 3cb80ff..ea31273 100644 --- a/PortfolioJournal/Views/Charts/ComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/ComparisonChartView.swift @@ -111,11 +111,9 @@ struct ComparisonChartView: View { .frame(height: 260) } - /// Permanent labels only when the chart stays readable (few points AND few series). - private var showAllLabels: Bool { - let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0 - return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3 - } + /// Multi-series comparison: labelling every point of every series overlaps + /// badly. Instead we label only each series' END point (where it landed), + /// which is the value the comparison is actually about. private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] { viewModel.comparisonData.compactMap { series in @@ -131,6 +129,7 @@ struct ComparisonChartView: View { Chart { ForEach(viewModel.comparisonData, id: \.id) { series in let seriesColor = Color(hex: series.colorHex) ?? .appPrimary + let lastDate = series.points.last?.date ForEach(series.points, id: \.date) { point in LineMark( x: .value("Date", point.date), @@ -145,9 +144,9 @@ struct ComparisonChartView: View { y: .value("Value", point.value) ) .foregroundStyle(seriesColor) - .symbolSize(showAllLabels ? 32 : 16) - .annotation(position: .top, spacing: 2) { - if showAllLabels { + .symbolSize(point.date == lastDate ? 30 : 14) + .annotation(position: .trailing, spacing: 2) { + if point.date == lastDate { ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor) } } diff --git a/PortfolioJournal/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift index 2b64fef..ba87a8a 100644 --- a/PortfolioJournal/Views/Charts/DrawdownChart.swift +++ b/PortfolioJournal/Views/Charts/DrawdownChart.swift @@ -5,8 +5,9 @@ struct DrawdownChart: View { let data: [(date: Date, drawdown: Double)] @State private var zoom = ChartZoomModel() @State private var selectedDate: Date? + @Environment(\.horizontalSizeClass) private var labelsSizeClass - private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var labelsCompact: Bool { labelsSizeClass != .regular } private var selectedPoint: (date: Date, drawdown: Double)? { guard let selectedDate else { return nil } return data.min(by: { @@ -46,7 +47,8 @@ struct DrawdownChart: View { if data.count >= 2 { Chart { - ForEach(data, id: \.date) { item in + ForEach(Array(data.enumerated()), id: \.element.date) { pair in + let item = pair.element AreaMark( x: .value("Date", item.date), y: .value("Drawdown", item.drawdown) @@ -72,9 +74,9 @@ struct DrawdownChart: View { y: .value("Drawdown", item.drawdown) ) .foregroundStyle(Color.negativeRed) - .symbolSize(showAllLabels ? 36 : 20) + .symbolSize(20) .annotation(position: .bottom, spacing: 3) { - if showAllLabels { + if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed) } } @@ -207,8 +209,9 @@ struct VolatilityChartView: View { let data: [(date: Date, volatility: Double)] @State private var zoom = ChartZoomModel() @State private var selectedDate: Date? + @Environment(\.horizontalSizeClass) private var labelsSizeClass - private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var labelsCompact: Bool { labelsSizeClass != .regular } private var selectedPoint: (date: Date, volatility: Double)? { guard let selectedDate else { return nil } return data.min(by: { @@ -240,7 +243,8 @@ struct VolatilityChartView: View { if data.count >= 2 { Chart { - ForEach(data, id: \.date) { item in + ForEach(Array(data.enumerated()), id: \.element.date) { pair in + let item = pair.element LineMark( x: .value("Date", item.date), y: .value("Volatility", item.volatility) @@ -266,9 +270,9 @@ struct VolatilityChartView: View { y: .value("Volatility", item.volatility) ) .foregroundStyle(Color.appPrimary) - .symbolSize(showAllLabels ? 36 : 20) + .symbolSize(20) .annotation(position: .top, spacing: 3) { - if showAllLabels { + if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) { ChartValueBubble(text: String(format: "%.1f%%", item.volatility)) } } diff --git a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift index f718f8c..6466343 100644 --- a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift @@ -6,10 +6,6 @@ struct PeriodComparisonChartView: View { @State private var selectedMonth: Int? /// Period charts are short by design (a handful of months) — labels always help. - private var showAllLabels: Bool { - (viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold - } - private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] { viewModel.periodComparisonData.compactMap { series in guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil } @@ -217,8 +213,18 @@ struct PeriodComparisonChartView: View { return result } + /// Last month offset per series — used to label only each series' endpoint. + private var lastOffsetBySeries: [String: Int] { + var m: [String: Int] = [:] + for s in viewModel.periodComparisonData { + m[s.id] = s.points.map(\.monthOffset).max() + } + return m + } + private var periodChart: some View { let points = flatPoints + let endpoints = lastOffsetBySeries let seriesIds = viewModel.periodComparisonData.map { $0.id } let seriesColors: [Color] = viewModel.periodComparisonData.map { Color(hex: $0.colorHex) ?? .gray @@ -240,10 +246,10 @@ struct PeriodComparisonChartView: View { y: .value("Return", point.returnPct) ) .foregroundStyle(by: .value("Period", point.seriesLabel)) - .symbolSize(showAllLabels ? 40 : 30) + .symbolSize(point.monthOffset == endpoints[point.seriesId] ? 38 : 22) .annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom, spacing: 3) { - if showAllLabels { + if point.monthOffset == endpoints[point.seriesId] { ChartValueBubble( text: ChartLabels.percent(point.returnPct), color: Color(hex: point.colorHex) ?? .gray diff --git a/PortfolioJournal/Views/Charts/PredictionChartView.swift b/PortfolioJournal/Views/Charts/PredictionChartView.swift index d4dcc09..6c511da 100644 --- a/PortfolioJournal/Views/Charts/PredictionChartView.swift +++ b/PortfolioJournal/Views/Charts/PredictionChartView.swift @@ -5,10 +5,9 @@ struct PredictionChartView: View { let predictions: [Prediction] let historicalData: [(date: Date, value: Decimal)] @State private var selectedDate: Date? + @Environment(\.horizontalSizeClass) private var labelsSizeClass - private var showAllLabels: Bool { - historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold - } + private var labelsCompact: Bool { labelsSizeClass != .regular } /// Nearest point (historical or predicted) to the selected date. private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? { @@ -49,8 +48,9 @@ struct PredictionChartView: View { if !predictions.isEmpty && historicalData.count >= 2 { Chart { - // Historical data - ForEach(historicalData, id: \.date) { item in + // Historical data — thin labels to at most a handful. + ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in + let item = pair.element LineMark( x: .value("Date", item.date), y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) @@ -63,9 +63,9 @@ struct PredictionChartView: View { y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) - .symbolSize(showAllLabels ? 36 : 24) + .symbolSize(24) .annotation(position: .top, spacing: 3) { - if showAllLabels { + if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) { ChartValueBubble(text: item.value.compactCurrencyString) } } @@ -95,9 +95,10 @@ struct PredictionChartView: View { y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue) ) .foregroundStyle(Color.appSecondary) - .symbolSize(showAllLabels ? 36 : 24) + .symbolSize(prediction.id == predictions.last?.id ? 34 : 24) .annotation(position: .top, spacing: 3) { - if showAllLabels { + // Only the final forecast point (the KPIs cover the rest). + if prediction.id == predictions.last?.id { ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary) } } diff --git a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift index 2eb7465..d9ce87a 100644 --- a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift +++ b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift @@ -93,8 +93,11 @@ struct YearOverYearChartView: View { // MARK: - Chart - /// Two selected years × 12 months stays readable with permanent labels. - private var showAllLabels: Bool { selectedSeries.count <= 2 } + /// Last month index that has data, per series id — label only that endpoint + /// (the year's cumulative return) to avoid 24 overlapping labels. + private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? { + (0..<12).last { !series.values[$0].isNaN } + } private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] { selectedSeries.compactMap { series in @@ -113,6 +116,7 @@ struct YearOverYearChartView: View { let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 let color = lineColors[idx % lineColors.count] let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 + let endMonth = lastValidMonth(yearSeries) ForEach(0..<12, id: \.self) { monthIdx in let value = yearSeries.values[monthIdx] if !value.isNaN { @@ -129,9 +133,9 @@ struct YearOverYearChartView: View { y: .value("Return", value) ) .foregroundStyle(color) - .symbolSize(showAllLabels ? 32 : 18) + .symbolSize(monthIdx == endMonth ? 34 : 18) .annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) { - if showAllLabels { + if monthIdx == endMonth { ChartValueBubble(text: ChartLabels.percent(value), color: color) } } diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index 8c78d72..6e69eae 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -25,15 +25,17 @@ struct DashboardView: View { ZStack { AppBackground() - ScrollView { - VStack(spacing: 16) { - // Calm 2.0: three zones — state (hero), action (check-in), - // context (everything else). The old top clutter (streak - // badge, pending banner, insight chips) folded into them. - if viewModel.hasData { - if horizontalSizeClass == .regular { - iPadDashboardLayout - } else { + // Calm 2.0: three zones — state (hero), action (check-in), + // context (everything else). The old top clutter (streak badge, + // pending banner, insight chips) folded into them. + if viewModel.hasData { + if horizontalSizeClass == .regular { + // iPad/Mac provides its own GeometryReader + ScrollView so + // the masonry can size columns to the window width. + iPadDashboardLayout + } else { + ScrollView { + VStack(spacing: 16) { ForEach(visibleSections) { config in sectionView(for: config) if config.id == DashboardSection.monthlyCheckIn.id, @@ -42,15 +44,18 @@ struct DashboardView: View { } } } - } else { - EmptyDashboardView( - onAddSource: { showingAddSource = true }, - onImport: { showingImport = true }, - onLoadSample: { SampleDataService.shared.seedSampleData() } - ) + .padding() } } - .padding() + } else { + ScrollView { + EmptyDashboardView( + onAddSource: { showingAddSource = true }, + onImport: { showingImport = true }, + onLoadSample: { SampleDataService.shared.seedSampleData() } + ) + .padding() + } } } .navigationTitle("Home") @@ -177,44 +182,59 @@ struct DashboardView: View { return leadIds.compactMap { id in visibleSections.first { $0.id == id } } } - /// Remaining context cards, split into two balanced masonry columns so the - /// dashboard fills the iPad width without the row-height coupling (and gaps) - /// a LazyVGrid produced. - private var iPadContextColumns: ([DashboardSectionConfig], [DashboardSectionConfig]) { + /// Rough relative height per section — drives greedy column balancing so the + /// masonry columns end up similar heights instead of one long, one short. + private func sectionWeight(_ id: String) -> Int { + switch DashboardSection(rawValue: id) { + case .momentumStreaks, .evolution: return 3 + case .categoryBreakdown, .goals: return 2 + default: return 1 + } + } + + /// Distributes the context cards into `count` masonry columns, greedily + /// placing each into the currently-shortest column (by accumulated weight). + private func iPadContextColumns(count: Int) -> [[DashboardSectionConfig]] { let leadIds = Set([DashboardSection.totalValue.id, DashboardSection.monthlyCheckIn.id]) let rest = visibleSections.filter { !leadIds.contains($0.id) } - var left: [DashboardSectionConfig] = [] - var right: [DashboardSectionConfig] = [] - for (i, cfg) in rest.enumerated() { - if i.isMultiple(of: 2) { left.append(cfg) } else { right.append(cfg) } + var columns = Array(repeating: [DashboardSectionConfig](), count: count) + var weights = Array(repeating: 0, count: count) + for cfg in rest { + let target = weights.enumerated().min(by: { $0.element < $1.element })?.offset ?? 0 + columns[target].append(cfg) + weights[target] += sectionWeight(cfg.id) } - return (left, right) + return columns } @ViewBuilder private var iPadDashboardLayout: some View { - let columns = iPadContextColumns - VStack(spacing: 16) { - ForEach(iPadLeadSections) { config in - sectionView(for: config) - if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty { - InsightsRow(insights: viewModel.insights) - } - } + GeometryReader { geo in + // Scale column count with available width so a wide Mac window fills + // (3 columns) while an iPad stays at 2. Lead cards span full width. + let columnCount = geo.size.width >= 1250 ? 3 : 2 + let columns = iPadContextColumns(count: columnCount) + ScrollView { + VStack(spacing: 16) { + ForEach(iPadLeadSections) { config in + sectionView(for: config) + if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty { + InsightsRow(insights: viewModel.insights) + } + } - HStack(alignment: .top, spacing: 16) { - VStack(spacing: 16) { - ForEach(columns.0) { sectionView(for: $0) } + HStack(alignment: .top, spacing: 16) { + ForEach(columns.indices, id: \.self) { i in + VStack(spacing: 16) { + ForEach(columns[i]) { sectionView(for: $0) } + } + .frame(maxWidth: .infinity) + } + } } - .frame(maxWidth: .infinity) - VStack(spacing: 16) { - ForEach(columns.1) { sectionView(for: $0) } - } - .frame(maxWidth: .infinity) + .padding() } } - .frame(maxWidth: 1100) - .frame(maxWidth: .infinity) } @ViewBuilder diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index dc8a290..9f5693f 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -543,12 +543,16 @@ struct MonthlyCheckInView: View { .foregroundColor(.secondary) } else { ForEach(viewModel.sources, id: \.objectID) { source in - let latestSnapshot = source.latestSnapshot - let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot) + // Month-aware: "updated" means updated FOR THE VIEWED month. + let monthSnapshot = snapshotForViewedMonth(source) + let updatedThisCycle = monthSnapshot != nil + let latestSnapshot = monthSnapshot ?? source.latestSnapshot let snapshots = source.sortedSnapshotsByDateAscending let previousSnapshot: Snapshot? = { - guard snapshots.count >= 2 else { return nil } - return snapshots[snapshots.count - 2] + guard let ref = latestSnapshot, + let idx = snapshots.firstIndex(where: { $0.objectID == ref.objectID }), + idx > 0 else { return nil } + return snapshots[idx - 1] }() let valueDiff: Decimal? = { guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil } @@ -837,6 +841,24 @@ private extension MonthlyCheckInView { return snapshot.date >= Calendar.current.startOfDay(for: last) } + /// The effective (year, month) of the check-in month currently being viewed. + private var viewedMonthComponents: DateComponents { + let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate) + return Calendar.current.dateComponents([.year, .month], from: effective) + } + + /// The source's snapshot that belongs to the VIEWED month (not the global + /// latest) — so a past month shows "Updated" for the sources it recorded, + /// instead of "Needs update" for everything. + func snapshotForViewedMonth(_ source: InvestmentSource) -> Snapshot? { + let target = viewedMonthComponents + return source.sortedSnapshotsByDateAscending.last { snapshot in + let eff = MonthlyCheckInStore.effectiveMonth(for: snapshot.date, relativeTo: snapshot.date) + let c = Calendar.current.dateComponents([.year, .month], from: eff) + return c.year == target.year && c.month == target.month + } + } + @ViewBuilder func moodPill(for mood: MonthlyCheckInMood) -> some View { let isSelected = selectedMood == mood