diff --git a/Modules/Sources/JetpackStats/Cards/StatsTodayCardController.swift b/Modules/Sources/JetpackStats/Cards/StatsTodayCardController.swift new file mode 100644 index 000000000000..e79d41fbc6a9 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/StatsTodayCardController.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Public, controller-driven entry point for embedding the Stats "Today" card +/// outside the Stats screen (currently the My Site dashboard). +/// +/// The internal `TodayCard`/`TodayCardViewModel` load only on first appearance +/// and expose no reload path, so an embedder that keeps a card on screen across +/// appearances needs an explicit lifecycle contract. This controller owns the +/// view model and forwards the reload/cancel decisions to it, keeping the +/// module's internals private. +@MainActor +public final class StatsTodayCardController: ObservableObject { + let context: StatsContext + let viewModel: TodayCardViewModel + + /// Called on the main actor when a load fails. The embedder uses this to log + /// the degraded state, since the dashboard installs no analytics tracker on + /// its `StatsContext` (so the module's own `trackError` is intentionally + /// silent there). + public var onLoadError: ((any Error) -> Void)? { + didSet { viewModel.onLoadFailure = onLoadError } + } + + public init(context: StatsContext) { + self.context = context + + let configuration = TodayCardConfiguration( + supportedMetrics: Set(context.service.supportedMetrics) + ) + self.viewModel = TodayCardViewModel( + configuration: configuration, + dateRange: context.calendar.makeDateRange(for: .today), + context: context + ) + } + + /// Reloads the current period only when the loaded data can no longer be + /// trusted (retry after failure, midnight rollover, or TTL staleness). + /// Otherwise a no-op, preserving the service cache. + public func refreshIfNeeded() { + viewModel.refreshIfNeeded() + } + + /// Cancels any in-flight load, so a stale response cannot land after the + /// controller is torn down (for example on a site switch). + public func cancel() { + viewModel.cancelLoading() + } +} + +/// Renders the internal Stats "Today" card for an embedder, so the visual result +/// is identical to the Stats screen's card. +public struct StatsTodayCardView: View { + @ObservedObject private var controller: StatsTodayCardController + private let menuContent: (() -> MenuContent)? + + /// Renders the card with caller-supplied more-menu items. The card still + /// draws the ellipsis button itself, so only the items differ. + public init(controller: StatsTodayCardController, @ViewBuilder menuContent: @escaping () -> MenuContent) { + self.controller = controller + self.menuContent = menuContent + } + + public var body: some View { + Group { + if let menuContent { + TodayCard(viewModel: controller.viewModel, menuContent: menuContent) + } else { + TodayCard(viewModel: controller.viewModel) + } + } + .environment(\.context, controller.context) + } +} + +extension StatsTodayCardView where MenuContent == EmptyView { + /// Uses the card's built-in more-menu (the Stats screen's behavior). + public init(controller: StatsTodayCardController) { + self.controller = controller + self.menuContent = nil + } +} diff --git a/Modules/Sources/JetpackStats/Cards/TodayCard.swift b/Modules/Sources/JetpackStats/Cards/TodayCard.swift index b9c947db166d..a7706e5e4fab 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCard.swift @@ -2,14 +2,20 @@ import SwiftUI import Charts import WordPressUI -struct TodayCard: View { +struct TodayCard: View { @ObservedObject private var viewModel: TodayCardViewModel + /// When non-nil, replaces the card's built-in more-menu items so an + /// embedding host (the My Site dashboard) can supply its own. The card keeps + /// rendering the ellipsis button itself, so only the items differ. + private let menuContent: (() -> MenuContent)? + @ScaledMetric(relativeTo: .title) private var sparklineHeight: CGFloat = 52 - init(viewModel: TodayCardViewModel) { + init(viewModel: TodayCardViewModel, @ViewBuilder menuContent: @escaping () -> MenuContent) { self.viewModel = viewModel + self.menuContent = menuContent } var body: some View { @@ -180,7 +186,11 @@ struct TodayCard: View { private var moreMenu: some View { Menu { - moreMenuContent + if let menuContent { + menuContent() + } else { + moreMenuContent + } } label: { Image(systemName: "ellipsis") .font(.system(size: 15)) @@ -201,6 +211,14 @@ struct TodayCard: View { } } +extension TodayCard where MenuContent == EmptyView { + /// Uses the card's built-in more-menu (the Stats screen's behavior). + init(viewModel: TodayCardViewModel) { + self.viewModel = viewModel + self.menuContent = nil + } +} + private struct SparklineChart: View { let dataPoints: [(hour: Int, value: Int)] let previousDataPoints: [(hour: Int, value: Int)] diff --git a/Modules/Sources/JetpackStats/Cards/TodayCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TodayCardViewModel.swift index 7a51d5b51952..015e08692046 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCardViewModel.swift @@ -15,6 +15,10 @@ final class TodayCardViewModel: ObservableObject, TrafficCardViewModel { weak var configurationDelegate: CardConfigurationDelegate? + /// Invoked on the main actor when a load fails. An embedding host uses this + /// to log the degraded state when no analytics tracker is installed. + var onLoadFailure: ((any Error) -> Void)? + var dateRange: StatsDateRangeSelection { didSet { loadData(for: dateRange.range.updating(preset: .today)) @@ -31,15 +35,31 @@ final class TodayCardViewModel: ObservableObject, TrafficCardViewModel { private var loadingTask: Task? private var isFirstAppear = true + /// Clock used for staleness/rollover decisions. Injected so host-lifecycle + /// tests can drive `refreshIfNeeded()` deterministically. + private let currentDate: @Sendable () -> Date + + /// When the most recent load last populated `data`. Used by + /// `refreshIfNeeded()` to decide whether the loaded data is stale. + private var lastLoadedAt: Date? + + /// Dashboard staleness window: past this age, `refreshIfNeeded()` reloads the + /// current period instead of trusting the in-memory data. Independent of, and + /// deliberately longer than, the `StatsService` cache TTL, because the + /// dashboard card's daily totals change slowly and it is revisited often. + private static let refreshTTL: TimeInterval = 300 + init( configuration: TodayCardConfiguration, dateRange: StatsDateRange, context: StatsContext, + currentDate: @escaping @Sendable () -> Date = { Date() } ) { self.configuration = configuration self.dateRange = StatsDateRangeSelection(range: dateRange.updating(preset: .today)) self.service = context.service self.tracker = context.tracker + self.currentDate = currentDate } func updateConfiguration(_ newConfiguration: TodayCardConfiguration) { @@ -60,9 +80,65 @@ final class TodayCardViewModel: ObservableObject, TrafficCardViewModel { loadData(for: effectiveDateRange) } + /// Reloads the current period when, and only when, the in-memory data can no + /// longer be trusted: a previous load failed (retry), the day rolled over + /// (the loaded range no longer contains the current time), or the data is + /// older than the service cache TTL (staleness). Otherwise it is a no-op, + /// preserving the `StatsService` cache. + /// + /// This is the host-controlled reload seam the dashboard drives on every + /// appearance; the Stats screen never calls it, so its behavior is unchanged. + func refreshIfNeeded() { + // A load is already in flight (including the initial `onAppear` load); + // let it finish rather than cancelling and restarting it. + guard !isLoading else { + return + } + + let now = currentDate() + + // Retry after a failed or never-completed load. + if data == nil || loadingError != nil { + reloadCurrentPeriod() + return + } + + // Midnight rollover: the loaded range no longer contains "now". + if !effectiveDateRange.dateInterval.contains(now) { + reloadCurrentPeriod() + return + } + + // Staleness: the loaded data is older than the service cache TTL. + if let lastLoadedAt, now.timeIntervalSince(lastLoadedAt) >= Self.refreshTTL { + reloadCurrentPeriod() + return + } + } + + /// Cancels any in-flight load. Used when the host is torn down (for example + /// a site switch) so a stale response cannot land on the wrong site. + func cancelLoading() { + loadingTask?.cancel() + loadingTask = nil + } + + /// Recomputes the `.today` range against the current date (rolling it over + /// when needed) and reloads. Assigning `dateRange` triggers its `didSet`, + /// which drives the load through the existing path. + private func reloadCurrentPeriod() { + dateRange = StatsDateRangeSelection(range: dateRange.range.updating(preset: .today)) + } + private func loadData(for dateRange: StatsDateRange) { loadingTask?.cancel() + // Reflect the in-flight state synchronously so a load scheduled here is + // observable immediately (the async task below also sets it, but not + // until it starts running). This keeps `refreshIfNeeded()`'s + // `isLoading` guard correct between back-to-back reloads. + isLoading = true + // Create a new loading task loadingTask = Task { [weak self] in guard let self else { return } @@ -80,11 +156,13 @@ final class TodayCardViewModel: ObservableObject, TrafficCardViewModel { try Task.checkCancellation() data = loadedData + lastLoadedAt = currentDate() } catch is CancellationError { return } catch { loadingError = error tracker?.trackError(error, screen: "today_card") + onLoadFailure?(error) } isLoading = false diff --git a/Modules/Tests/JetpackStatsTests/StatsTodayCardControllerTests.swift b/Modules/Tests/JetpackStatsTests/StatsTodayCardControllerTests.swift new file mode 100644 index 000000000000..540a9b000ec5 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsTodayCardControllerTests.swift @@ -0,0 +1,261 @@ +import Foundation +import Testing +@preconcurrency import WordPressKit +@testable import JetpackStats + +/// Covers the controller-driven lifecycle the My Site dashboard relies on: +/// `refreshIfNeeded()` retry/staleness/rollover/no-op decisions and `cancel()`. +@MainActor +@Suite +struct StatsTodayCardControllerTests { + + @Test("refreshIfNeeded retries after a failed load") + func retriesAfterFailure() async { + let service = ControllableStatsService() + await service.setShouldFail(true) + let vm = Self.makeViewModel(service: service) + + vm.onAppear() + await Self.waitUntilIdle(vm) + + #expect(vm.data == nil) + #expect(vm.loadingError != nil) + let countAfterFailure = await service.loadCount + + await service.setShouldFail(false) + vm.refreshIfNeeded() + await Self.waitUntilIdle(vm) + + #expect(vm.data != nil) + #expect(vm.loadingError == nil) + #expect(await service.loadCount > countAfterFailure) + } + + @Test("refreshIfNeeded reloads when the data is older than the refresh window") + func reloadsWhenStale() async { + let clock = Clock() + let service = ControllableStatsService() + let vm = Self.makeViewModel(service: service, clock: clock) + + vm.onAppear() + await Self.waitUntilIdle(vm) + #expect(vm.data != nil) + let count = await service.loadCount + + // Past the 300s refresh window. + clock.now = clock.now.addingTimeInterval(301) + vm.refreshIfNeeded() + await Self.waitUntilIdle(vm) + + #expect(await service.loadCount > count) + } + + @Test("refreshIfNeeded is a no-op when the data is still fresh") + func noOpWhenFresh() async { + let clock = Clock() + let service = ControllableStatsService() + let vm = Self.makeViewModel(service: service, clock: clock) + + vm.onAppear() + await Self.waitUntilIdle(vm) + #expect(vm.data != nil) + let count = await service.loadCount + + // Within the TTL, same day. + clock.now = clock.now.addingTimeInterval(5) + vm.refreshIfNeeded() + await Task.yield() + + #expect(await service.loadCount == count) + } + + @Test("refreshIfNeeded reloads after the day rolls over") + func reloadsAfterRollover() async { + let clock = Clock() + let service = ControllableStatsService() + let vm = Self.makeViewModel(service: service, clock: clock) + + vm.onAppear() + await Self.waitUntilIdle(vm) + #expect(vm.data != nil) + let count = await service.loadCount + + // Two days later: the loaded range no longer contains "now". + clock.now = clock.now.addingTimeInterval(2 * 24 * 60 * 60) + vm.refreshIfNeeded() + await Self.waitUntilIdle(vm) + + #expect(await service.loadCount > count) + } + + @Test("cancel cancels an in-flight load") + func cancelCancelsInFlightLoad() async { + let service = ControllableStatsService() + await service.setHoldLoads(true) + let vm = Self.makeViewModel(service: service) + + vm.onAppear() + // Let the load enter the (held) service call. + try? await Task.sleep(for: .milliseconds(30)) + #expect(vm.isLoading) + #expect(vm.data == nil) + + vm.cancelLoading() + try? await Task.sleep(for: .milliseconds(40)) + + // A cancelled load neither populates data nor surfaces an error. + #expect(vm.data == nil) + #expect(vm.loadingError == nil) + } + + @Test("the controller forwards refresh and reports load failures") + func controllerForwardsRefreshAndReportsFailures() async { + let service = ControllableStatsService() + await service.setShouldFail(true) + let context = StatsContext(timeZone: .current, siteID: 1, service: service) + let controller = StatsTodayCardController(context: context) + + var reportedError: (any Error)? + controller.onLoadError = { reportedError = $0 } + + controller.viewModel.onAppear() + await Self.waitUntilIdle(controller.viewModel) + + #expect(controller.viewModel.data == nil) + #expect(reportedError != nil) + + await service.setShouldFail(false) + let count = await service.loadCount + controller.refreshIfNeeded() + await Self.waitUntilIdle(controller.viewModel) + + #expect(controller.viewModel.data != nil) + #expect(await service.loadCount > count) + } + + // MARK: - Helpers + + private static func makeViewModel( + service: ControllableStatsService, + clock: Clock = Clock() + ) -> TodayCardViewModel { + let context = StatsContext(timeZone: .current, siteID: 1, service: service) + return TodayCardViewModel( + configuration: TodayCardConfiguration(metrics: [.views, .visitors]), + dateRange: context.calendar.makeDateRange(for: .today), + context: context, + currentDate: { clock.now } + ) + } + + private static func waitUntilIdle(_ viewModel: TodayCardViewModel) async { + for _ in 0..<500 { + if !viewModel.isLoading { + return + } + try? await Task.sleep(for: .milliseconds(2)) + } + } +} + +/// A settable clock so staleness and rollover decisions are deterministic. +private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var _now: Date + + init(_ now: Date = Date()) { + _now = now + } + + var now: Date { + get { + lock.lock() + defer { lock.unlock() } + return _now + } + set { + lock.lock() + defer { lock.unlock() } + _now = newValue + } + } +} + +/// A `StatsServiceProtocol` stub whose `getSiteStats` can fail, be held in-flight +/// (to test cancellation), and reports how many times it was called. +private actor ControllableStatsService: StatsServiceProtocol { + nonisolated let supportedMetrics: [SiteMetric] = [.views, .visitors, .likes, .comments] + nonisolated let supportedItems: [TopListItemType] = [] + + nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + [.views] + } + + private(set) var loadCount = 0 + private var shouldFail = false + private var holdLoads = false + + func setShouldFail(_ value: Bool) { + shouldFail = value + } + + func setHoldLoads(_ value: Bool) { + holdLoads = value + } + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse { + loadCount += 1 + + if holdLoads { + // Hang until the enclosing task is cancelled. + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(5)) + } + throw CancellationError() + } + + if shouldFail { + throw TestError.load + } + + return SiteMetricsResponse(total: SiteMetricsSet(), metrics: [:]) + } + + // The Today card does not use the methods below. + func getWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse { + throw TestError.unused + } + + func getWordAdsEarnings() async throws -> WordPressKit.StatsWordAdsEarningsResponse { + throw TestError.unused + } + + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, options: TopListItemOptions) async throws -> TopListResponse { + throw TestError.unused + } + + func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListResponse { + throw TestError.unused + } + + func getPostDetails(for postID: Int) async throws -> StatsPostDetails { + throw TestError.unused + } + + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { + throw TestError.unused + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + throw TestError.unused + } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + throw TestError.unused + } +} + +private enum TestError: Error { + case load + case unused +} diff --git a/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardCardTests.swift b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardCardTests.swift index 00d41ec8f868..75f26df64c71 100644 --- a/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardCardTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardCardTests.swift @@ -15,12 +15,17 @@ class DashboardCardTests: CoreDataTestCase { blog.isAdmin = true featureFlags.override(RemoteFeatureFlag.activityLogDashboardCard, withValue: true) featureFlags.override(RemoteFeatureFlag.pagesDashboardCard, withValue: true) + // These cases assert on the legacy `.todaysStats` card, which is only + // shown when new Stats is off (the new card is otherwise mutually + // exclusive). Pin the flag so a stale simulator override can't flip it. + featureFlags.override(FeatureFlag.newStats, withValue: false) } override func tearDown() { blog = nil featureFlags.override(RemoteFeatureFlag.activityLogDashboardCard, withValue: RemoteFeatureFlag.activityLogDashboardCard.originalValue) featureFlags.override(RemoteFeatureFlag.pagesDashboardCard, withValue: RemoteFeatureFlag.pagesDashboardCard.originalValue) + featureFlags.override(FeatureFlag.newStats, withValue: FeatureFlag.newStats.originalValue) super.tearDown() } diff --git a/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardTodayStatsNewCardTests.swift b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardTodayStatsNewCardTests.swift new file mode 100644 index 000000000000..e5f73f12a3d4 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Dashboard/DashboardTodayStatsNewCardTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import WordPress +@testable import WordPressData + +/// Covers the `.todaysStats` / `.todaysStatsNew` mutual exclusion across the +/// `newStats` flag crossed with context availability, plus the shared +/// personalization/analytics identity. +class DashboardTodayStatsNewCardTests: CoreDataTestCase { + + private let featureFlags = FeatureFlagOverrideStore() + + override func setUp() { + super.setUp() + contextManager.useAsSharedInstance(untilTestFinished: self) + } + + override func tearDown() { + featureFlags.override(FeatureFlag.newStats, withValue: FeatureFlag.newStats.originalValue) + super.tearDown() + } + + // MARK: - Mutual exclusion + + func testFlagOnShowsNewCardAndHidesLegacy() { + featureFlags.override(FeatureFlag.newStats, withValue: true) + let blog = makeEligibleBlog() + let apiResponse = buildStatsEntity() + + XCTAssertTrue(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: apiResponse)) + XCTAssertFalse(DashboardCard.todaysStats.shouldShow(for: blog, apiResponse: apiResponse)) + } + + func testFlagOffShowsLegacyCardAndHidesNew() { + featureFlags.override(FeatureFlag.newStats, withValue: false) + let blog = makeEligibleBlog() + let apiResponse = buildStatsEntity() + + XCTAssertTrue(DashboardCard.todaysStats.shouldShow(for: blog, apiResponse: apiResponse)) + XCTAssertFalse(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: apiResponse)) + } + + // MARK: - Fallback when the context is unavailable + + func testFlagOnFallsBackToLegacyWhenNoAuthToken() { + featureFlags.override(FeatureFlag.newStats, withValue: true) + // An account with an empty auth token cannot build a StatsContext, so the + // eligibility predicate must fail and the legacy card must show instead. + let blog = BlogBuilder(mainContext).withAnAccount(authToken: "").build() + blog.isAdmin = true + let apiResponse = buildStatsEntity() + + XCTAssertFalse(DashboardCard.newStatsActive(for: blog)) + XCTAssertFalse(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: apiResponse)) + XCTAssertTrue(DashboardCard.todaysStats.shouldShow(for: blog, apiResponse: apiResponse)) + } + + func testFlagOnFallsBackToLegacyWithoutDotComID() { + featureFlags.override(FeatureFlag.newStats, withValue: true) + let blog = BlogBuilder(mainContext, dotComID: nil).withAnAccount().build() + blog.isAdmin = true + let apiResponse = buildStatsEntity() + + XCTAssertFalse(DashboardCard.newStatsActive(for: blog)) + XCTAssertFalse(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: apiResponse)) + } + + // MARK: - Remote gating still applies + + func testNewCardHiddenWithoutRemoteStats() { + featureFlags.override(FeatureFlag.newStats, withValue: true) + let blog = makeEligibleBlog() + + XCTAssertFalse(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: nil)) + XCTAssertFalse(DashboardCard.todaysStatsNew.shouldShow(for: blog, apiResponse: buildStatsEntity(hasStats: false))) + } + + // MARK: - Flag transition changes the diffable item identity + + func testFlagTransitionProducesDifferentItemIdentity() { + let apiResponse = buildStatsEntity() + let legacyItem = DashboardNormalCardModel(cardType: .todaysStats, dotComID: 1, entity: apiResponse) + let newItem = DashboardNormalCardModel(cardType: .todaysStatsNew, dotComID: 1, entity: apiResponse) + + // Same site and payload, different rendering: the diffable data source + // must treat these as different items so the cell swaps on a flag flip. + XCTAssertNotEqual(legacyItem, newItem) + XCTAssertNotEqual(DashboardItem.cards(.normal(legacyItem)), DashboardItem.cards(.normal(newItem))) + } + + // MARK: - Shared personalization + analytics identity + + func testSharesPersonalizationKeyWithLegacyCard() { + XCTAssertEqual( + DashboardCard.todaysStatsNew.blogDashboardPersonalizationKey, + DashboardCard.todaysStats.blogDashboardPersonalizationKey + ) + } + + func testNewCardIsNotIndependentlyPersonalizable() { + XCTAssertFalse(DashboardCard.personalizableCards.contains(.todaysStatsNew)) + XCTAssertTrue(DashboardCard.personalizableCards.contains(.todaysStats)) + } + + func testAnalyticsReportLegacyCardIdentity() { + XCTAssertEqual( + DashboardCard.todaysStatsNew.analyticProperties["card"] as? String, + DashboardCard.todaysStats.rawValue + ) + } + + // MARK: - Helpers + + private func makeEligibleBlog() -> Blog { + let blog = BlogBuilder(mainContext).withAnAccount().build() + blog.isAdmin = true + return blog + } + + private func buildStatsEntity(hasStats: Bool = true) -> BlogDashboardRemoteEntity { + let stats = hasStats + ? FailableDecodable(value: BlogDashboardRemoteEntity.BlogDashboardStats(views: 1, visitors: 2, likes: 3, comments: 0)) + : nil + return BlogDashboardRemoteEntity(posts: nil, todaysStats: stats, pages: nil, activity: nil) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardTodayStatsNewCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardTodayStatsNewCardCell.swift new file mode 100644 index 000000000000..4072ff0d805c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardTodayStatsNewCardCell.swift @@ -0,0 +1,214 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressData +import WordPressShared +import WordPressKit +import Logging + +/// Dashboard cell that renders the new Stats "Today" card (the same UI, sparkline +/// included, as the new Stats screen) when `FeatureFlag.newStats` is enabled. +/// +/// It is frameless: `StatsTodayCardView` brings its own card chrome, header, and +/// date, so wrapping it in `BlogDashboardCardFrameView` would double the title. +/// The card fetches its own hourly data through the module (independent of the +/// dashboard's batched `todays_stats` payload, which lacks hourly series and +/// still gates visibility). +final class DashboardTodayStatsNewCardCell: DashboardCollectionViewCell { + + private weak var presentingViewController: BlogDashboardViewController? + + private var controller: StatsTodayCardController? + private var hostingController: UIHostingController? + + /// The dotCom ID the current `controller` was built for, so a site switch (cell + /// reuse across sites, or an in-place switch on My Site) can be detected and + /// the controller rebuilt instead of showing the previous site's data. + private var controllerSiteID: Int? + + private static let logger = Logger(label: "org.wordpress.dashboard.stats-today") + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + observeAppForeground() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + observeAppForeground() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func prepareForReuse() { + super.prepareForReuse() + teardownController() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + refreshIfVisible() + } + + private func observeAppForeground() { + NotificationCenter.default.addObserver( + self, + selector: #selector(refreshIfVisible), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + /// Retries a failed load, refreshes TTL-expired data, and rolls the range + /// over after midnight. Covers the cases where `configure` is not called + /// again: scrolling the cell back into view / a tab switch (`didMoveToWindow`) + /// and returning from the background (`willEnterForeground`), where the + /// dashboard reapplies an unchanged snapshot without re-configuring the cell. + /// `refreshIfNeeded()` is a no-op when the data is fresh, so this is cheap. + @objc private func refreshIfVisible() { + if window != nil { + controller?.refreshIfNeeded() + } + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + guard let viewController else { + return + } + + self.presentingViewController = viewController + + let siteID = blog.dotComID?.intValue + if let controller, let siteID, controllerSiteID == siteID { + // Same site: keep the controller (preserving the service cache) and refresh. + controller.refreshIfNeeded() + } else { + // First configuration, or a site switch: cancel any in-flight load + // and rebuild the context, controller, and hosted view. + rebuildController(for: blog, in: viewController) + } + + guard controller != nil else { + return + } + + // Fire the same card-shown analytics as the legacy card (reporting the + // `todays_stats` identity). The dashboard's `StatsContext` has no tracker, + // so the module emits nothing; these dashboard events are the only ones. + BlogDashboardAnalytics.shared.track( + .dashboardCardShown, + properties: ["type": DashboardCard.todaysStats.rawValue], + blog: blog + ) + } + + // MARK: - Controller lifecycle + + private func rebuildController(for blog: Blog, in viewController: BlogDashboardViewController) { + teardownController() + + guard let context = StatsContext.dashboard(blog: blog) else { + // Eligibility passed (`DashboardCard.newStatsActive`) but the context + // could not be built (e.g. a logout raced this parse). Render nothing + // and log; the next dashboard parse selects the legacy card. + Self.logger.error("Failed to build StatsContext for the dashboard Today card (site: \(blog.dotComID?.intValue ?? -1))") + return + } + + let controller = StatsTodayCardController(context: context) + controller.onLoadError = { error in + // The dashboard context installs no tracker, so log the degraded + // state here to keep it diagnosable without touching analytics. + Self.logger.error("Stats Today card load failed: \(String(describing: error))") + } + self.controller = controller + self.controllerSiteID = blog.dotComID?.intValue + + // Wrap the whole card in a plain Button (mirroring the Stats screen's + // TrafficTabView) so the tap carries button/accessibility semantics + // instead of a bare tap gesture. The card's own ellipsis Menu keeps + // working nested inside, exactly as on the Stats screen. + let rootView = Button(action: { [weak self] in + self?.showStats(for: blog) + }) { + StatsTodayCardView(controller: controller) { [weak self] in + if let self { + self.makeMenu(for: blog) + } + } + } + .buttonStyle(.plain) + + let hostingController = UIHostingController(rootView: AnyView(rootView)) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.willMove(toParent: viewController) + viewController.addChild(hostingController) + contentView.addSubview(hostingController.view) + contentView.pinSubviewToAllEdges(hostingController.view, priority: UILayoutPriority(999)) + hostingController.didMove(toParent: viewController) + hostingController.view.invalidateIntrinsicContentSize() + self.hostingController = hostingController + } + + private func teardownController() { + controller?.cancel() + controller = nil + controllerSiteID = nil + hostingController?.willMove(toParent: nil) + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + } + + // MARK: - Dashboard menu + + @ViewBuilder + private func makeMenu(for blog: Blog) -> some View { + Button(action: { [weak self] in + self?.showStats(for: blog) + }) { + Label(Strings.viewStats, systemImage: "chart.bar.xaxis") + } + Button(role: .destructive, action: { [weak self] in + self?.hideCard(for: blog) + }) { + Label(Strings.hideThis, systemImage: "minus.circle") + } + } + + // MARK: - Actions + + private func showStats(for blog: Blog) { + WPAnalytics.track( + .dashboardCardItemTapped, + properties: ["type": DashboardCard.todaysStats.rawValue], + blog: blog + ) + RootViewCoordinator.sharedPresenter.showStats(for: blog, source: .todayStatsCard, tab: .traffic, unit: .day, date: nil) + } + + private func hideCard(for blog: Blog) { + // Mirrors `BlogDashboardHelpers.makeHideCardAction`. Writing the shared + // personalization key hides both the legacy and the new rendering. + BlogDashboardAnalytics.trackHideTapped(for: DashboardCard.todaysStatsNew) + BlogDashboardPersonalizationService(siteID: blog.dotComID?.intValue ?? 0) + .setEnabled(false, for: DashboardCard.todaysStatsNew) + } +} + +// MARK: - Constants + +private extension DashboardTodayStatsNewCardCell { + + enum Strings { + static let viewStats = NSLocalizedString("dashboardCard.stats.viewStats", value: "View stats", comment: "Title for the View stats button in the More menu") + static let hideThis = NSLocalizedString("blogDashboard.contextMenu.hideThis", value: "Hide this", comment: "Title for the context menu action that hides the dashboard card.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard+Personalization.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard+Personalization.swift index d400a2540d01..27b0a9995765 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard+Personalization.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard+Personalization.swift @@ -4,7 +4,9 @@ extension DashboardCard: BlogDashboardPersonalizable { var blogDashboardPersonalizationKey: String? { switch self { - case .todaysStats: + case .todaysStats, .todaysStatsNew: + // Both renderings share one key, so the single Personalize Home Tab + // toggle and either card's "Hide this" control both of them. return "todays-stats-card-enabled-site-settings" case .draftPosts: return "draft-posts-card-enabled-site-settings" diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard.swift index b875b6cdee86..29336d9d2d0e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Models/DashboardCard.swift @@ -18,6 +18,10 @@ enum DashboardCard: String, CaseIterable, Sendable { case freeToPaidPlansDashboardCard case domainRegistration case todaysStats = "todays_stats" + /// The new Stats "Today" card (matches the new Stats screen). Declared + /// adjacent to `todaysStats` so it occupies the same dashboard position; + /// the two are mutually exclusive (see `shouldShow`). + case todaysStatsNew = "todays_stats_new" case draftPosts case scheduledPosts case pages @@ -45,6 +49,8 @@ enum DashboardCard: String, CaseIterable, Sendable { return DashboardScheduledPostsCardCell.self case .todaysStats: return DashboardStatsCardCell.self + case .todaysStatsNew: + return DashboardTodayStatsNewCardCell.self case .prompts: return DashboardPromptsCardCell.self case .ghost: @@ -103,7 +109,13 @@ enum DashboardCard: String, CaseIterable, Sendable { case .draftPosts, .scheduledPosts: return shouldShowRemoteCard(apiResponse: apiResponse) case .todaysStats: - return DashboardStatsCardCell.shouldShowCard(for: blog) && shouldShowRemoteCard(apiResponse: apiResponse) + return DashboardStatsCardCell.shouldShowCard(for: blog) + && shouldShowRemoteCard(apiResponse: apiResponse) + && !DashboardCard.newStatsActive(for: blog) + case .todaysStatsNew: + return DashboardCard.newStatsActive(for: blog) + && DashboardStatsCardCell.shouldShowCard(for: blog) + && shouldShowRemoteCard(apiResponse: apiResponse) case .prompts: return DashboardPromptsCardCell.shouldShowCard(for: blog) case .extensiveLogging: @@ -145,6 +157,27 @@ enum DashboardCard: String, CaseIterable, Sendable { isJetpack && RemoteDashboardCard.dynamic.supported(by: blog) } + /// Whether the new Stats "Today" card is eligible for `blog`. + /// + /// This is intentionally a side-effect-free predicate over stored properties + /// only. It must NOT construct `StatsContext` or read + /// `WPAccount.wordPressComRestApi`: that getter posts the + /// sign-in-presenting `.wpAccountRequiresShowingSigninForWPComFixingAuthToken` + /// notification when the token is missing, and the `StatsContext(blog:)` + /// factory calls `wpAssertionFailure` on its failure path. Both are + /// unacceptable during routine dashboard parsing, where failing this check is + /// the normal legacy-fallback signal, not a bug. The stored `authToken` is + /// read directly precisely because it has no such side effects. + static func newStatsActive(for blog: Blog) -> Bool { + guard FeatureFlag.newStats.enabled, + blog.dotComID != nil, + let authToken = blog.account?.authToken, + !authToken.isEmpty else { + return false + } + return true + } + private func shouldShowRemoteCard(apiResponse: BlogDashboardRemoteEntity?) -> Bool { guard let apiResponse else { return false @@ -154,7 +187,7 @@ enum DashboardCard: String, CaseIterable, Sendable { return apiResponse.hasDrafts case .scheduledPosts: return apiResponse.hasScheduled - case .todaysStats: + case .todaysStats, .todaysStatsNew: return apiResponse.hasStats case .pages: return apiResponse.hasPages @@ -229,6 +262,13 @@ private extension BlogDashboardRemoteEntity { extension DashboardCard: BlogDashboardAnalyticPropertiesProviding { var analyticProperties: [AnyHashable: Any] { - ["card": rawValue] + switch self { + case .todaysStatsNew: + // Report the same card identity as the legacy card so existing + // card-shown/tapped funnels stay comparable across the flag. + return ["card": DashboardCard.todaysStats.rawValue] + default: + return ["card": rawValue] + } } } diff --git a/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift index 488ee565deb6..e747a3903933 100644 --- a/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift @@ -116,7 +116,10 @@ private extension DashboardCard { .failure, .personalize, .jetpackBadge, .jetpackInstall, .empty, .freeToPaidPlansDashboardCard, .domainRegistration, - .googleDomains, .extensiveLogging: + .googleDomains, .extensiveLogging, + // The new Stats card shares the legacy card's personalization entry, + // so only `.todaysStats` appears in the personalization menu. + .todaysStatsNew: assertionFailure("\(self) card should not appear in the personalization menus") return "" // These cards don't appear in the personalization menus } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 74aebd4f00ad..ecc3911ac43f 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -79,6 +79,28 @@ extension StatsContext { self.upgradeURL = Self.makeUpgradeURL(for: blog) } + /// A context for embedding the Today card on the My Site dashboard: the same + /// as the Stats-screen context but with NO analytics tracker, so the + /// embedded card never feeds the Stats-screen funnels (the dashboard fires + /// its own card-shown/tapped events, matching the legacy card). + /// + /// It reuses `StatsContext(blog:)`, which asserts on failure. That is an + /// accepted, deliberate trade-off: `DashboardCard.newStatsActive` only + /// selects this card for WP.com-connected sites with a non-empty auth token, + /// and `WPAccount.wordPressComRestApi` is non-nil whenever the token is + /// non-empty, so construction succeeds in practice. The assertion is + /// reachable only if a logout races the synchronous, main-thread + /// parse -> configure path, which is effectively unreachable. On that + /// theoretical failure the cell renders the empty state and falls back to + /// the legacy card. See the cross-agent code review discussion. + static func dashboard(blog: Blog) -> StatsContext? { + guard var context = StatsContext(blog: blog) else { + return nil + } + context.tracker = nil + return context + } + private static func makeUpgradeURL(for blog: Blog) -> URL { if blog.isHostedAtWPcom { return URL(string: "https://wordpress.com/pricing/")!