Skip to content

Stats Review: Add types to represent date period, granularity, presets, and more (P4) - #24716

Closed
kean wants to merge 1 commit into
review/stats-data-typesfrom
review/date-range-types
Closed

Stats Review: Add types to represent date period, granularity, presets, and more (P4)#24716
kean wants to merge 1 commit into
review/stats-data-typesfrom
review/date-range-types

Conversation

@kean

@kean kean commented Aug 5, 2025

Copy link
Copy Markdown
Contributor

Add the following types and respective unit tests:

  • DateIntervalPreset and Calendar+Presets to represent the presents like Last 7 Days
  • DateRangeComparisonPeriod to represent a comparison period (last period or same period last year)
  • Calendar+Navigation to implement "backward" and "forward" actions
  • StatsDateFormatter and StatsDateRangeFormatter to format individual dates and date periods (see unit tests and docs)
  • StatsValueFormatter to format values for metrics (SiteMetric)

Alternative considered: using NaiveDate or date components to represent date periods. I tried it, but it turned out completely impractical as you still needed to convert to Date and use Calendar for all manipulations (which there are many needed). So instead, it uses Date with a site timezone. The site time zone is used throughout the module.

@kean kean added this to the Pending milestone Aug 5, 2025
@kean kean added the Stats label Aug 5, 2025
@kean kean changed the title Add types to represent date period, granularity, presents and formatters for them Stats Review: Add types to represent date period, granularity, presets, and more (P4) Aug 5, 2025
@kean kean mentioned this pull request Aug 5, 2025
14 tasks
@dangermattic

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2025

Copy link
Copy Markdown

case .month: components.month ?? 1
case .quarter: components.quarter ?? 1
case .year: components.year ?? 1
default: 1

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.

Replace the switch statement with components.value(for: component) ?? 1?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in #24760

/// - direction: Whether to navigate forward (.forward) or backward (.backward)
/// - component: The calendar component to use for navigation
/// - Returns: A new date interval representing the navigated period
func navigate(_ interval: DateInterval, direction: NavigationDirection, component: Calendar.Component) -> DateInterval {

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.

I don't get the meaning of the component parameter.

Take one of the unit tests as an example:

// 7-day period should navigate by 7 days
(Date("2025-01-08T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00"), Calendar.Component.day, ...)

// Using above test arguments makes call to
navigate(
  DateInterval(start: "2025-01-08", end: "2025-01-15"),
  direction: .backwards,
  component: .day
)

Why passing .day as the component argument? Is the component argument even necessary?

The navigate function calculates the immediate previous or next period of a given date interval. It feels like only the first two arguments are needed.

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.

I just saw the determineNavigationComponent function below. Is the component argument here always determineNavigationComponent(interval) in practice? If that's the case, we can omit the component and call determineNavigationComponent internally within the navigate function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is the component argument even necessary?

The component is needed because the following are two different scenarios:

// Returns `DateInterval(start: "2025-05-31", end: "2025-06-30")` as it offsets by "31 days" (number of days in July)
navigate(
  DateInterval(start: "2025-07-01", end: "2025-07-31"),
  direction: .backwards,
  component: .day
)

// Returns `DateInterval(start: "2025-06-01", end: "2025-06-30")` as it offsets by "1 month" 
navigate(
  DateInterval(start: "2025-07-01", end: "2025-07-31"),
  direction: .backwards,
  component: .month
)

I wanted to keep func navigate flexible and simple.

Is the component argument here always determineNavigationComponent(interval) in practice?

When you select a present (DateRangePreset), the component is based on the preset. The only component determineNavigationComponent currently doesn't support is quarter. I added it in #24760. I'm not sure if I want to use determineNavigationComponent automatically just yet – I might be missing something.

}

/// Determines if navigation is allowed in the specified direction
func canNavigate(_ interval: DateInterval, direction: NavigationDirection, minYear: Int = 2000, now: Date = .now) -> Bool {

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.

Can this function be implemented like this?

let minYearsAgo = ...
let validInterval = DateInteravl(start: minYearsAgo, end: now)
let dest = navigate(interval, direction)
return validInterval.intersects(dest)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd probably keep the current implementation for now. It seems a bit more direct.

}
}
}
}

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.

This function already exists in WordPressUI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Turns out it was unused already – removed.

}

func string(from interval: DateInterval, now: Date = Date()) -> String {
var calendar = Calendar.current

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.

This is an existing issue with the app. But I thought I'd mention it anyways. We should not use Calendar.current to calculate dates. What the app needs almost 100% of the time is the Gregorian calendar, which Calendar.current may not be, because users may set their iPhone calendar to be other calendars. For example, many people in China set their phone to use the Chinese Calendar.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@kean kean closed this Aug 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants