Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Modules/Sources/JetpackStats/Cards/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions Modules/Sources/JetpackStats/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,29 @@ 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")
static let hourlyDataUnavailable = AppLocalizedString("jetpackStats.chart.hourlyDataNotAvailable", value: "Hourly data not available", comment: "Shown for metrics that don't support hourly data")
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 {
Expand Down
116 changes: 116 additions & 0 deletions Modules/Sources/JetpackStats/Views/ChartComparisonLegend.swift
Original file line number Diff line number Diff line change
@@ -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] : []
)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}