diff --git a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift index e2a395464ad1..3d82bd27b58d 100644 --- a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift +++ b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift @@ -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" diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift index fd144eb1a975..53af3032991a 100644 --- a/Modules/Sources/JetpackStats/Charts/BarChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -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) @@ -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 } diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift index 548fa161adc0..91acdc6ac3c0 100644 --- a/Modules/Sources/JetpackStats/Charts/LineChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -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), @@ -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 diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift index a532551da4f9..b1f1348d40de 100644 --- a/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift @@ -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 @@ -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 @@ -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 @@ -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 } } @@ -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 { @@ -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) @@ -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) diff --git a/Modules/Sources/JetpackStats/StatsViewModel.swift b/Modules/Sources/JetpackStats/StatsViewModel.swift index 681ddfc11fa3..399c891a2aac 100644 --- a/Modules/Sources/JetpackStats/StatsViewModel.swift +++ b/Modules/Sources/JetpackStats/StatsViewModel.swift @@ -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 ) @@ -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) } } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 43e011778de8..37cc87be3c63 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -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") diff --git a/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift index 342cb4542507..5da4b0759b6f 100644 --- a/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift @@ -19,11 +19,12 @@ struct StatsDateRangePickerMenu: View { makePresetButtons(for: [ .last7Days, .last30Days, - .last12Months, + .last12Months ]) Menu { Section { makePresetButtons(for: [ + .last14Days, .last28Days, .last12Weeks, .last6Months, diff --git a/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift b/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift index 4cf769b17276..633b10ed8c9a 100644 --- a/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift +++ b/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift @@ -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")), @@ -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 @@ -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")), @@ -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")), diff --git a/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift index 77989299fe2b..b56a754b4b47 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift @@ -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 @@ -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") diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 6ad22d51028f..b2e520bffc05 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -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