import SwiftUI import Charts struct YearOverYearChartView: View { @ObservedObject var viewModel: ChartsViewModel private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] private let lineColors: [Color] = [ .appPrimary, .appSecondary, .orange, .purple, .pink, .teal ] private var allYears: [ChartsViewModel.YearSeries] { viewModel.yearOverYearData } private var selectedSeries: [ChartsViewModel.YearSeries] { allYears.filter { viewModel.yoySelectedYears.contains($0.year) } .sorted { $0.year < $1.year } } var body: some View { VStack(alignment: .leading, spacing: 16) { Text(String(localized: "chart_yoy_title")) .font(.headline) if allYears.isEmpty { Text(String(localized: "chart_yoy_empty")) .font(.subheadline) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .frame(height: 200) } else { yearSelector if !selectedSeries.isEmpty { // KPIs arriba del todo (#147) yearEndStatsRow if selectedSeries.count == 2 { comparisonStatsHeader } if hasEstimate { Text(String(localized: "chart_yoy_estimated_note")) .font(.caption2) .foregroundColor(.secondary) } // Detalle debajo (#147) chartBody legend if selectedSeries.count == 2 { comparisonTable } } } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } // MARK: - Year Selector private var yearSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(allYears) { ys in let idx = allYears.firstIndex(where: { $0.id == ys.id }) ?? 0 let color = lineColors[idx % lineColors.count] let isSelected = viewModel.yoySelectedYears.contains(ys.year) Button { if isSelected { viewModel.yoySelectedYears.remove(ys.year) } else { viewModel.yoySelectedYears.insert(ys.year) } } label: { Text(String(ys.year)) .font(.caption.weight(.medium)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1)) .foregroundColor(isSelected ? color : .secondary) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(isSelected ? color : Color.clear, lineWidth: 1.5) ) .cornerRadius(16) } .buttonStyle(.plain) } } } } // MARK: - Chart @ViewBuilder private var chartBody: some View { if #available(iOS 16.0, *) { Chart { ForEach(selectedSeries) { yearSeries in let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 let color = lineColors[idx % lineColors.count] ForEach(0..<12, id: \.self) { monthIdx in let value = yearSeries.values[monthIdx] if !value.isNaN { LineMark( x: .value("Month", monthIdx), y: .value("Return", value), series: .value("Year", String(yearSeries.year)) ) .foregroundStyle(color) .interpolationMethod(.catmullRom) } } } } .chartXAxis { AxisMarks(values: Array(0..<12)) { value in AxisValueLabel { if let idx = value.as(Int.self) { Text(monthAbbreviations[idx]) .font(.caption2) } } } } .chartYAxis { AxisMarks { value in AxisValueLabel { if let d = value.as(Double.self) { Text(String(format: "%+.1f%%", d)) .font(.caption2) } } AxisGridLine() } } .frame(height: 220) } else { Text("iOS 16+ required for chart") .foregroundColor(.secondary) .frame(height: 220) } } // MARK: - Legend private var legend: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(selectedSeries) { yearSeries in let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 HStack(spacing: 6) { RoundedRectangle(cornerRadius: 2) .fill(lineColors[idx % lineColors.count]) .frame(width: 20, height: 3) Text(String(yearSeries.year)) .font(.caption) .foregroundColor(.secondary) } } } } } // MARK: - Year-end stats chips private var yearEndStatsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(selectedSeries) { yearSeries in let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 let color = lineColors[idx % lineColors.count] let end = endValue(yearSeries) VStack(alignment: .center, spacing: 3) { Text(String(yearSeries.year)) .font(.caption2) .foregroundColor(.secondary) Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : "")) .font(.subheadline.weight(.semibold)) .foregroundColor(end.value >= 0 ? color : .negativeRed) } .frame(minWidth: 60) .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color(.systemGray6)) .cornerRadius(8) } } } } // MARK: - 2-year comparison: KPIs (top) and table (bottom) @ViewBuilder private var comparisonStatsHeader: some View { let yearA = selectedSeries[0] let yearB = selectedSeries[1] let diffs = monthDiffs(yearA: yearA, yearB: yearB) let validDiffs = diffs.compactMap { $0 } let avgDiff = validDiffs.isEmpty ? 0 : validDiffs.reduce(0, +) / Double(validDiffs.count) let endAInfo = endValue(yearA) let endBInfo = endValue(yearB) let endDiff = endBInfo.value - endAInfo.value let endEstimated = endAInfo.estimated || endBInfo.estimated let bestDiff = validDiffs.max() ?? 0 let worstDiff = validDiffs.min() ?? 0 VStack(alignment: .leading, spacing: 8) { Text("\(yearA.year) vs \(yearB.year)") .font(.subheadline.weight(.semibold)) ChartStatsRow(stats: [ ChartStat(label: "Avg diff", value: String(format: "%+.1f%%", avgDiff), color: avgDiff >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "End diff", value: String(format: "%+.1f%%", endDiff) + (endEstimated ? "*" : ""), color: endDiff >= 0 ? .positiveGreen : .negativeRed), ChartStat(label: "Best month", value: String(format: "%+.1f%%", bestDiff), color: .positiveGreen), ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff), color: .negativeRed), ]) } } @ViewBuilder private var comparisonTable: some View { let yearA = selectedSeries[0] let yearB = selectedSeries[1] ChartDataTable( rows: comparisonTableRows(yearA: yearA, yearB: yearB), valueHeader: String(yearA.year), deltaPrevHeader: String(yearB.year), deltaFirstHeader: "Diff" ) } // MARK: - Helpers /// Effective year-end value: the forecast (estimated) for the current incomplete year, /// otherwise the last month with real data. private func endValue(_ s: ChartsViewModel.YearSeries) -> (value: Double, estimated: Bool) { if let f = s.forecastEndValue { return (f, true) } return (s.values.last(where: { !$0.isNaN }) ?? 0, false) } private var hasEstimate: Bool { selectedSeries.contains { $0.forecastEndValue != nil } } private func monthDiffs(yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries) -> [Double?] { (0..<12).map { idx in let a = yearA.values[idx] let b = yearB.values[idx] guard !a.isNaN, !b.isNaN else { return nil } return b - a } } private func comparisonTableRows( yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries ) -> [ChartDataTableRow] { (0..<12).compactMap { idx in let valA = yearA.values[idx] let valB = yearB.values[idx] guard !valA.isNaN || !valB.isNaN else { return nil } let diff = (!valA.isNaN && !valB.isNaN) ? valB - valA : 0 return ChartDataTableRow( label: monthAbbreviations[idx], value: valA.isNaN ? "—" : String(format: "%+.1f%%", valA), deltaPrev: valB.isNaN ? "—" : String(format: "%+.1f%%", valB), deltaFirst: (!valA.isNaN && !valB.isNaN) ? String(format: "%+.1f%%", diff) : "—", isPrevPositive: valB >= 0, isFirstPositive: diff >= 0 ) } } }