diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index c06c600595d4..e1cfd6b446a7 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -254,6 +254,17 @@ struct ChartCard: View { @ViewBuilder private func mainChartView(metric: SiteMetric, data: ChartData) -> some View { VStack(alignment: .leading, spacing: Constants.step1 / 2) { + if dateRange.comparison != .off { + ChartComparisonLegend( + model: ChartComparisonLegendModel( + dateRange: dateRange, + chartType: selectedChartType, + formatter: context.formatters.dateRange + ), + metric: metric + ) + } + chartContentView(data: data) .frame(height: chartHeight) .padding(.horizontal, -Constants.step1) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 319c5afe1d4d..a76272272843 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -158,6 +158,8 @@ enum Strings { enum Chart { static let showData = AppLocalizedString("jetpackStats.chart.showData", value: "Show Data", comment: "Show chart data menu item") + static let selectedPeriod = AppLocalizedString("jetpackStats.chart.legend.selectedPeriod", value: "Selected period", comment: "Accessibility label for the selected chart period") + static let comparisonPeriod = AppLocalizedString("jetpackStats.chart.legend.comparisonPeriod", value: "Comparison period", comment: "Accessibility label for the comparison chart period") static let lineChart = AppLocalizedString("jetpackStats.chart.lineChart", value: "Lines", comment: "Line chart type") static let barChart = AppLocalizedString("jetpackStats.chart.barChart", value: "Bars", comment: "Bar chart type") static let incompleteData = AppLocalizedString("jetpackStats.chart.incompleteData", value: "Might show incomplete data", comment: "Shown when current period data might be incomplete") @@ -165,6 +167,20 @@ enum Strings { static let empty = AppLocalizedString("jetpackStats.chart.dataEmpty", value: "No data for period", comment: "Shown for empty states") static let granularity = AppLocalizedString("jetpackStats.chart.granularity", value: "Granularity", comment: "Granularity picker label") static let other = AppLocalizedString("jetpackStats.chart.other", value: "Other", comment: "Label for aggregated 'Other' segment in pie charts") + + static func comparisonLegendItem(comparison: String, range: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.chart.legend.comparisonLabel", value: "%1$@ · %2$@", comment: "Chart legend label for a comparison period. %1$@ is the comparison type and %2$@ is its date range."), + comparison, range + ) + } + + static func legendItem(period: String, range: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.chart.legend.accessibilityLabel", value: "%1$@: %2$@", comment: "Accessibility label for a chart legend item. %1$@ is the period type and %2$@ is the date range."), + period, range + ) + } } enum TopListTitles { diff --git a/Modules/Sources/JetpackStats/Views/ChartComparisonLegend.swift b/Modules/Sources/JetpackStats/Views/ChartComparisonLegend.swift new file mode 100644 index 000000000000..ac155ccd39fa --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartComparisonLegend.swift @@ -0,0 +1,116 @@ +import SwiftUI + +enum ChartComparisonLegendStyle: Equatable { + case lines + case bars +} + +struct ChartComparisonLegendModel: Equatable { + let currentPeriod: String + let comparisonPeriod: String + let style: ChartComparisonLegendStyle + + init( + dateRange: StatsDateRange, + chartType: ChartType, + formatter: StatsDateRangeFormatter + ) { + currentPeriod = formatter.string(from: dateRange.dateInterval) + comparisonPeriod = Strings.Chart.comparisonLegendItem( + comparison: dateRange.comparison.localizedTitle, + range: formatter.string(from: dateRange.effectiveComparisonInterval) + ) + style = chartType == .line ? .lines : .bars + } +} + +struct ChartComparisonLegend: View { + let model: ChartComparisonLegendModel + let metric: SiteMetric + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: Constants.step2) { + currentPeriod + .fixedSize(horizontal: true, vertical: false) + comparisonPeriod + .fixedSize(horizontal: true, vertical: false) + } + VStack(alignment: .leading, spacing: Constants.step0_5) { + currentPeriod + comparisonPeriod + } + } + } + + private var currentPeriod: some View { + legendItem( + label: model.currentPeriod, + accessibilityPeriod: Strings.Chart.selectedPeriod, + isComparison: false + ) + } + + private var comparisonPeriod: some View { + legendItem( + label: model.comparisonPeriod, + accessibilityPeriod: Strings.Chart.comparisonPeriod, + isComparison: true + ) + } + + private func legendItem( + label: String, + accessibilityPeriod: String, + isComparison: Bool + ) -> some View { + HStack(spacing: Constants.step1) { + swatch(isComparison: isComparison) + .accessibilityHidden(true) + Text(label) + .font(.caption2) + .foregroundStyle(Color.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.Chart.legendItem(period: accessibilityPeriod, range: label)) + } + + @ViewBuilder + private func swatch(isComparison: Bool) -> some View { + switch model.style { + case .lines: + ChartLegendLineSwatch( + color: isComparison ? Color.secondary.opacity(0.8) : metric.primaryColor, + isDashed: isComparison + ) + .frame(width: 24, height: 8) + case .bars: + RoundedRectangle(cornerRadius: 2) + .fill(isComparison ? Color.secondary.opacity(0.25) : metric.primaryColor) + .frame(width: 16, height: 10) + } + } +} + +private struct ChartLegendLineSwatch: View { + let color: Color + let isDashed: Bool + + var body: some View { + GeometryReader { geometry in + Path { path in + let y = geometry.size.height / 2 + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width, y: y)) + } + .stroke( + color, + style: StrokeStyle( + lineWidth: isDashed ? 2 : 3, + lineCap: .round, + dash: isDashed ? [5, 6] : [] + ) + ) + } + } +} diff --git a/Modules/Tests/JetpackStatsTests/ChartComparisonLegendModelTests.swift b/Modules/Tests/JetpackStatsTests/ChartComparisonLegendModelTests.swift new file mode 100644 index 000000000000..f52f3b991f25 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/ChartComparisonLegendModelTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import JetpackStats + +@Suite +struct ChartComparisonLegendModelTests { + private let calendar = Calendar.mock(timeZone: .eastern) + private let formatter = StatsDateRangeFormatter( + locale: Locale(identifier: "en_US"), + timeZone: .eastern, + now: { Date("2026-08-17T12:00:00-03:00") } + ) + + @Test("Preceding-period legend uses concrete intervals for a preset") + func precedingPeriodUsesConcreteIntervals() { + let dateRange = StatsDateRange( + interval: DateInterval( + start: Date("2026-08-08T00:00:00-03:00"), + end: Date("2026-08-15T00:00:00-03:00") + ), + component: .day, + comparison: .precedingPeriod, + calendar: calendar, + preset: .last7Days + ) + + let model = ChartComparisonLegendModel( + dateRange: dateRange, + chartType: .line, + formatter: formatter + ) + + #expect(model.currentPeriod == "Aug 8 – 14") + #expect(model.comparisonPeriod == "Preceding Period · Aug 1 – 7") + #expect(model.style == .lines) + } + + @Test("Last-year legend names the comparison and includes its year") + func lastYearIncludesComparisonYear() { + let dateRange = StatsDateRange( + interval: DateInterval( + start: Date("2026-08-08T00:00:00-03:00"), + end: Date("2026-08-15T00:00:00-03:00") + ), + component: .day, + comparison: .samePeriodLastYear, + calendar: calendar + ) + + let model = ChartComparisonLegendModel( + dateRange: dateRange, + chartType: .columns, + formatter: formatter + ) + + #expect(model.currentPeriod == "Aug 8 – 14") + #expect(model.comparisonPeriod == "Last Year · Aug 8 – 14, 2025") + #expect(model.style == .bars) + } + + @Test("Custom cross-year legend formats both concrete intervals") + func customCrossYearRange() { + let dateRange = StatsDateRange( + interval: DateInterval( + start: Date("2025-12-28T00:00:00-03:00"), + end: Date("2026-01-04T00:00:00-03:00") + ), + component: .day, + comparison: .precedingPeriod, + calendar: calendar + ) + + let model = ChartComparisonLegendModel( + dateRange: dateRange, + chartType: .line, + formatter: formatter + ) + + #expect(model.currentPeriod == "Dec 28, 2025 – Jan 3, 2026") + #expect(model.comparisonPeriod == "Preceding Period · Dec 21 – 27, 2025") + } +}