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
1 change: 1 addition & 0 deletions Modules/Sources/JetpackStats/Analytics/StatsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ extension DateIntervalPreset {
case .thisQuarter: "this_quarter"
case .thisYear: "this_year"
case .last7Days: "last_7_days"
case .last14Days: "last_14_days"
case .last28Days: "last_28_days"
case .last30Days: "last_30_days"
case .last12Weeks: "last_12_weeks"
Expand Down
48 changes: 35 additions & 13 deletions Modules/Sources/JetpackStats/Charts/BarChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,50 @@ struct BarChartView: View {
@ChartContentBuilder
private var currentPeriodBars: some ChartContent {
ForEach(data.currentData) { point in
let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity)
BarMark(
x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar),
y: .value("Value", point.value),
width: .automatic
)
.foregroundStyle(
LinearGradient(
colors: [
data.metric.primaryColor,
lighten(data.metric.primaryColor)
],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundStyle(isIncomplete ? AnyShapeStyle(incompleteBarPattern) : AnyShapeStyle(barGradient))
.cornerRadius(4)
.opacity(getOpacityForCurrentPeriodBar(for: point))
}
}

private var barGradient: LinearGradient {
LinearGradient(
colors: [data.metric.primaryColor, lighten(data.metric.primaryColor)],
startPoint: .top,
endPoint: .bottom
)
}

/// A tiling diagonal-stripe pattern for today's incomplete bar.
private var incompleteBarPattern: ImagePaint {
let color = UIColor(data.metric.primaryColor)
let tileSize: CGFloat = 10
let renderer = UIGraphicsImageRenderer(size: CGSize(width: tileSize, height: tileSize))
let image = renderer.image { ctx in
color.withAlphaComponent(0.33).setFill()
ctx.fill(CGRect(x: 0, y: 0, width: tileSize, height: tileSize))

let cg = ctx.cgContext
cg.setStrokeColor(color.withAlphaComponent(0.5).cgColor)
cg.setLineWidth(1.5)
// Three parallel lines for seamless tiling
cg.move(to: CGPoint(x: -tileSize, y: tileSize))
cg.addLine(to: CGPoint(x: tileSize, y: -tileSize))
cg.move(to: CGPoint(x: 0, y: tileSize))
cg.addLine(to: CGPoint(x: tileSize, y: 0))
cg.move(to: CGPoint(x: 0, y: tileSize * 2))
cg.addLine(to: CGPoint(x: tileSize * 2, y: 0))
cg.strokePath()
}
return ImagePaint(image: Image(uiImage: image), scale: 1)
}

private func lighten(_ color: Color) -> Color {
if #available(iOS 18, *) {
color.mix(with: Color(.systemBackground), by: colorScheme == .light ? 0.2 : 0.1)
Expand All @@ -93,9 +117,7 @@ struct BarChartView: View {
if tappedDataPoint != nil {
return 0.5
}
// If no selection and not tapped, check if data is incomplete
let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity)
return isIncomplete ? 0.5 : 1
return 1
}
return selectedDataPoints.current?.id == point.id ? 1.0 : 0.5
}
Expand Down
40 changes: 39 additions & 1 deletion Modules/Sources/JetpackStats/Charts/LineChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ struct LineChartView: View {

@ChartContentBuilder
private var currentPeriodMarks: some ChartContent {
ForEach(data.currentData) { point in
// Solid line and area for complete data points
ForEach(completeDataPoints) { point in
AreaMark(
x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar),
y: .value("Value", point.value),
Expand Down Expand Up @@ -83,6 +84,43 @@ struct LineChartView: View {
))
.interpolationMethod(.linear)
}

// Dashed line segment connecting the last complete point to today's incomplete point
ForEach(incompleteSegmentPoints) { point in
LineMark(
x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar),
y: .value("Value", point.value),
series: .value("Period", "Incomplete")
)
.foregroundStyle(data.metric.primaryColor.opacity(0.4))
.lineStyle(StrokeStyle(
lineWidth: 3,
lineCap: .round,
lineJoin: .round,
dash: [6, 5]
))
.interpolationMethod(.linear)
}
}

/// All data points except the incomplete (today's) point.
private var completeDataPoints: [DataPoint] {
guard let last = data.currentData.last,
context.calendar.isIncompleteDataPeriod(for: last.date, granularity: data.granularity) else {
return data.currentData
}
return Array(data.currentData.dropLast())
}

/// The last complete point and today's incomplete point, forming the dashed segment.
/// Empty when there is no incomplete data.
private var incompleteSegmentPoints: [DataPoint] {
guard data.currentData.count >= 2,
let last = data.currentData.last,
context.calendar.isIncompleteDataPeriod(for: last.date, granularity: data.granularity) else {
return []
}
return [data.currentData[data.currentData.count - 2], last]
}

@ChartContentBuilder
Expand Down
27 changes: 14 additions & 13 deletions Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ enum DateIntervalPreset: String, CaseIterable, Identifiable {
case thisQuarter
/// The current calendar year
case thisYear
/// The previous 7 days, not including today
/// The last 7 days, including today
case last7Days
/// The previous 28 days, not including today
/// The last 14 days, including today
case last14Days
/// The last 28 days, including today
case last28Days
/// The previous 30 days, not including today
/// The last 30 days, including today
case last30Days
/// The previous 12 weeks (84 days), not including today
/// The last 12 weeks (84 days), including today
case last12Weeks
/// The last 6 months, including the current month
case last6Months
Expand All @@ -40,6 +42,7 @@ enum DateIntervalPreset: String, CaseIterable, Identifiable {
case .thisQuarter: Strings.Calendar.thisQuarter
case .thisYear: Strings.Calendar.thisYear
case .last7Days: Strings.Calendar.last7Days
case .last14Days: Strings.Calendar.last14Days
case .last28Days: Strings.Calendar.last28Days
case .last30Days: Strings.Calendar.last30Days
case .last12Weeks: Strings.Calendar.last12Weeks
Expand All @@ -52,7 +55,7 @@ enum DateIntervalPreset: String, CaseIterable, Identifiable {

var prefersDateIntervalFormatting: Bool {
switch self {
case .today, .last7Days, .last28Days, .last30Days, .last12Weeks, .last6Months, .last12Months, .thisWeek:
case .today, .last7Days, .last14Days, .last28Days, .last30Days, .last12Weeks, .last6Months, .last12Months, .thisWeek:
return false
case .thisMonth, .thisYear, .thisQuarter, .last3Years, .last10Years:
return true
Expand All @@ -72,7 +75,7 @@ enum DateIntervalPreset: String, CaseIterable, Identifiable {
return .quarter
case .thisYear, .last3Years, .last10Years:
return .year
case .last7Days, .last28Days, .last30Days, .last12Weeks:
case .last7Days, .last14Days, .last28Days, .last30Days, .last12Weeks:
return .day
}
}
Expand All @@ -95,10 +98,10 @@ extension Calendar {
/// // Start: 2025-01-15 00:00:00
/// // End: 2025-01-16 00:00:00
///
/// // Last 7 days: Returns previous 7 complete days, not including today
/// // Last 7 days: Returns the last 7 days, including today
/// let last7 = calendar.makeDateInterval(for: .last7Days, now: now)
/// // Start: 2025-01-08 00:00:00
/// // End: 2025-01-15 00:00:00
/// // Start: 2025-01-09 00:00:00
/// // End: 2025-01-16 00:00:00
/// ```
func makeDateInterval(for preset: DateIntervalPreset, now: Date = .now) -> DateInterval {
switch preset {
Expand All @@ -108,6 +111,7 @@ extension Calendar {
case .thisQuarter: makeDateInterval(of: .quarter, for: now)
case .thisYear: makeDateInterval(of: .year, for: now)
case .last7Days: makeDateInterval(offset: -7, component: .day, for: now)
case .last14Days: makeDateInterval(offset: -14, component: .day, for: now)
case .last28Days: makeDateInterval(offset: -28, component: .day, for: now)
case .last30Days: makeDateInterval(offset: -30, component: .day, for: now)
case .last12Weeks: makeDateInterval(offset: -84, component: .day, for: now)
Expand All @@ -127,10 +131,7 @@ extension Calendar {
}

private func makeDateInterval(offset: Int, component: Component, for date: Date) -> DateInterval {
var endDate = makeDateInterval(of: component, for: date).end
if component == .day {
endDate = self.date(byAdding: .day, value: -1, to: endDate) ?? endDate
}
let endDate = makeDateInterval(of: component, for: date).end
guard let startDate = self.date(byAdding: component, value: offset, to: endDate), endDate >= startDate else {
assertionFailure("Failed to calculate start date for \(offset) \(component) from \(endDate)")
return DateInterval(start: date, end: date)
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/StatsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ final class StatsViewModel: ObservableObject, CardConfigurationDelegate {
let preset = Self.loadDateRangePreset(from: userDefaults)
let comparison = Self.loadComparisonPeriod(from: userDefaults)
self.dateRange = context.calendar.makeDateRange(
for: preset ?? .last7Days,
for: preset ?? .last14Days,
comparison: comparison ?? .precedingPeriod
)

Expand Down Expand Up @@ -346,6 +346,6 @@ final class StatsViewModel: ObservableObject, CardConfigurationDelegate {
userDefaults.removeObject(forKey: Self.comparisonPeriodKey)

// Reset date range to default
dateRange = context.calendar.makeDateRange(for: .last7Days)
dateRange = context.calendar.makeDateRange(for: .last14Days)
}
}
1 change: 1 addition & 0 deletions Modules/Sources/JetpackStats/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum Strings {
static let thisQuarter = AppLocalizedString("jetpackStats.calendar.thisQuarter", value: "This Quarter", comment: "This quarter date range")
static let thisYear = AppLocalizedString("jetpackStats.calendar.thisYear", value: "This Year", comment: "This year date range")
static let last7Days = AppLocalizedString("jetpackStats.calendar.last7Days", value: "Last 7 Days", comment: "Last 7 days date range")
static let last14Days = AppLocalizedString("jetpackStats.calendar.last14Days", value: "Last 14 Days", comment: "Last 14 days date range")
static let last28Days = AppLocalizedString("jetpackStats.calendar.last28Days", value: "Last 28 Days", comment: "Last 28 days date range")
static let last30Days = AppLocalizedString("jetpackStats.calendar.last30Days", value: "Last 30 Days", comment: "Last 30 days date range")
static let last12Weeks = AppLocalizedString("jetpackStats.calendar.last12Weeks", value: "Last 12 Weeks", comment: "Last 12 weeks (84 days) date range")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ struct StatsDateRangePickerMenu: View {
makePresetButtons(for: [
.last7Days,
.last30Days,
.last12Months,
.last12Months
])
Menu {
Section {
makePresetButtons(for: [
.last14Days,
.last28Days,
.last12Weeks,
.last6Months,
Expand Down
28 changes: 14 additions & 14 deletions Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ struct CalendarDateRangePresetTests {
(.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")),
(.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
(.last7Days, Date("2025-01-08T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")),
(.last28Days, Date("2024-12-18T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")),
(.last30Days, Date("2024-12-16T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")),
(.last12Weeks, Date("2024-10-23T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")),
(.last7Days, Date("2025-01-09T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00")),
(.last28Days, Date("2024-12-19T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00")),
(.last30Days, Date("2024-12-17T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00")),
(.last12Weeks, Date("2024-10-24T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00")),
(.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
Expand Down Expand Up @@ -47,8 +47,8 @@ struct CalendarDateRangePresetTests {
}

@Test("Preset handles year boundary correctly", arguments: [
(DateIntervalPreset.last7Days, Date("2024-12-29T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00")),
(DateIntervalPreset.last30Days, Date("2024-12-06T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00"))
(DateIntervalPreset.last7Days, Date("2024-12-30T00:00:00-03:00"), Date("2025-01-06T00:00:00-03:00")),
(DateIntervalPreset.last30Days, Date("2024-12-07T00:00:00-03:00"), Date("2025-01-06T00:00:00-03:00"))
])
func presetYearBoundary(preset: DateIntervalPreset, expectedStart: Date, expectedEnd: Date) {
// GIVEN
Expand Down Expand Up @@ -112,10 +112,10 @@ struct CalendarDateRangePresetTests {
(.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")),
(.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
(.last7Days, Date("2025-01-24T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")),
(.last28Days, Date("2025-01-03T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")),
(.last30Days, Date("2025-01-01T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")),
(.last12Weeks, Date("2024-11-08T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")),
(.last7Days, Date("2025-01-25T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last28Days, Date("2025-01-04T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last30Days, Date("2025-01-02T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last12Weeks, Date("2024-11-09T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
Expand All @@ -139,10 +139,10 @@ struct CalendarDateRangePresetTests {
(.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")),
(.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
(.last7Days, Date("2024-12-25T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")),
(.last28Days, Date("2024-12-04T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")),
(.last30Days, Date("2024-12-02T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")),
(.last12Weeks, Date("2024-10-09T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")),
(.last7Days, Date("2024-12-26T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00")),
(.last28Days, Date("2024-12-05T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00")),
(.last30Days, Date("2024-12-03T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00")),
(.last12Weeks, Date("2024-10-10T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00")),
(.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")),
(.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ struct StatsDateRangeFormatterTests {
(DateIntervalPreset.thisWeek, "Mar 9 – 15"),
(DateIntervalPreset.thisMonth, "Mar 2025"),
(DateIntervalPreset.thisYear, "2025"),
(DateIntervalPreset.last7Days, "Mar 8 – 14"),
(DateIntervalPreset.last30Days, "Feb 13 – Mar 14")
(DateIntervalPreset.last7Days, "Mar 9 – 15"),
(DateIntervalPreset.last30Days, "Feb 14 – Mar 15")
])
func dateRangePresetFormattingCurrentYear(preset: DateIntervalPreset, expected: String) {
// Set up a specific date in 2025
Expand All @@ -177,7 +177,7 @@ struct StatsDateRangeFormatterTests {

// Last 30 days crosses year boundary
let last30Days = calendar.makeDateInterval(for: .last30Days, now: testNow)
#expect(formatter.string(from: last30Days, now: testNow) == "Dec 6, 2024 – Jan 4, 2025")
#expect(formatter.string(from: last30Days, now: testNow) == "Dec 7, 2024 – Jan 5, 2025")
}

@Test("DateRangePreset formatting - custom ranges")
Expand Down
3 changes: 3 additions & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
26.8
-----
* [*] Fix an issue Social Sharing placeholder card showing unsupported services [#25336]
* [*] Stats: Include Today in "Last X" intervals like "Last 7 days" [#25361]
* [*] Stats: Add new "Last 14 days" period [#25361]
* [*] Stats: The incomplete dates (include today) are displayed using dotted line on a line chart and are more visible on a bar chart too [#25361]


26.7
Expand Down