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
82 changes: 82 additions & 0 deletions Modules/Sources/JetpackStats/Cards/StatsTodayCardController.swift
Original file line number Diff line number Diff line change
@@ -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<MenuContent: View>: 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
}
}
24 changes: 21 additions & 3 deletions Modules/Sources/JetpackStats/Cards/TodayCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import SwiftUI
import Charts
import WordPressUI

struct TodayCard: View {
struct TodayCard<MenuContent: View>: 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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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)]
Expand Down
78 changes: 78 additions & 0 deletions Modules/Sources/JetpackStats/Cards/TodayCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -31,15 +35,31 @@ final class TodayCardViewModel: ObservableObject, TrafficCardViewModel {
private var loadingTask: Task<Void, Never>?
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) {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel() stops the load but leaves the card marked as still loading, so it can never refresh again. Harmless today (the card is thrown away right after cancel()), but this one line removes the trap in case the card is ever reused instead of rebuilt.

Suggested change
loadingTask = nil
loadingTask = nil
isLoading = false

}

/// 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 }
Expand All @@ -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
Expand Down
Loading