*/ private const PRESETS = array( 'today' => array( 0, 0 ), 'yesterday' => array( 1, 1 ), '7d' => array( 7, 1 ), '28d' => array( 28, 1 ), '90d' => array( 90, 1 ), '12m' => array( 365, 1 ), ); /** * Preset used when the requested one is unknown or an invalid custom range. * * @var string */ private const FALLBACK = '28d'; /** * How far back a custom range may start, in years. * * @var int */ private const MAX_YEARS_BACK = 3; /** * GSC data lags; GSC ranges end this many days before today. * * @var int */ private const GSC_LAG_DAYS = 3; /** * Constructor. * * @param string $start Start date, Y-m-d. * @param string $end End date, Y-m-d. * @param string|null $compare_start Comparison start, Y-m-d, or null for no comparison. * @param string|null $compare_end Comparison end, Y-m-d, or null for no comparison. * @param string $preset Preset this range came from. */ public function __construct( public string $start, public string $end, public ?string $compare_start = null, public ?string $compare_end = null, public string $preset = 'custom' ) {} /** * Builds a range from request parameters. A custom end in the future is * clamped to today and a custom start older than MAX_YEARS_BACK is * clamped up to earliest_date() (the range stays 'custom'); unknown * presets and invalid custom ranges (including one that ends before the * earliest date) fall back to 28d. * * @param string $preset One of today, yesterday, 7d, 28d, 90d, 12m, custom. * @param string|null $start Custom start, Y-m-d. * @param string|null $end Custom end, Y-m-d. * @param bool $compare Whether to add the previous period of equal length. * @param string|null $today Today, Y-m-d. Defaults to the site's current date. */ public static function from_request( string $preset, ?string $start = null, ?string $end = null, bool $compare = false, ?string $today = null ): self { $today_ts = self::parse( $today ?? self::site_today() ) ?? self::parse( gmdate( 'Y-m-d' ) ); $today_ts = (int) $today_ts; $start_ts = null; $end_ts = null; if ( 'custom' === $preset ) { $start_ts = null === $start ? null : self::parse( $start ); $end_ts = null === $end ? null : self::parse( $end ); $end_ts = null === $end_ts ? null : min( $end_ts, $today_ts ); // No future dates. $earliest = self::earliest_ts( $today_ts ); $start_ts = null === $start_ts ? null : max( $start_ts, $earliest ); // Clamp, don't discard. if ( null === $start_ts || null === $end_ts || $start_ts > $end_ts ) { $start_ts = null; $end_ts = null; } } if ( null === $start_ts || null === $end_ts ) { $preset = isset( self::PRESETS[ $preset ] ) ? $preset : self::FALLBACK; $offsets = self::PRESETS[ $preset ]; $start_ts = self::add_days( $today_ts, -$offsets[0] ); $end_ts = self::add_days( $today_ts, -$offsets[1] ); } $range = new self( gmdate( 'Y-m-d', $start_ts ), gmdate( 'Y-m-d', $end_ts ), null, null, $preset ); if ( $compare ) { $range->set_compare(); } return $range; } /** * The earliest date a custom range may start, Y-m-d (today minus * MAX_YEARS_BACK years). Exposed to the client for the date picker. * * @param string|null $today Today, Y-m-d. Defaults to the site's current date. */ public static function earliest_date( ?string $today = null ): string { $today_ts = (int) ( self::parse( $today ?? self::site_today() ) ?? self::parse( gmdate( 'Y-m-d' ) ) ); return gmdate( 'Y-m-d', self::earliest_ts( $today_ts ) ); } /** * Whether the range reaches today (its data is still changing). * * @param string|null $today Today, Y-m-d. Defaults to the site's current date. */ public function includes_today( ?string $today = null ): bool { $today = $today ?? self::site_today(); return $this->start <= $today && $this->end >= $today; } /** * The range adjusted for Search Console's data lag: the end is clamped to * today minus 3 days and the start (and comparison) shifted by the same * amount to keep the length. * * @param string|null $today Today, Y-m-d. Defaults to the site's current date. */ public function for_gsc( ?string $today = null ): self { $today_ts = (int) ( self::parse( $today ?? self::site_today() ) ?? self::parse( gmdate( 'Y-m-d' ) ) ); $cutoff = self::add_days( $today_ts, -self::GSC_LAG_DAYS ); $end_ts = (int) self::parse( $this->end ); $shift = $end_ts > $cutoff ? (int) round( ( $end_ts - $cutoff ) / DAY_IN_SECONDS ) : 0; if ( 0 === $shift ) { return clone $this; } return new self( self::shift( $this->start, -$shift ), self::shift( $this->end, -$shift ), null === $this->compare_start ? null : self::shift( $this->compare_start, -$shift ), null === $this->compare_end ? null : self::shift( $this->compare_end, -$shift ), $this->preset ); } /** * Array form, used in cache keys and response meta. * * @return array{preset: string, start: string, end: string, compare_start: string|null, compare_end: string|null} */ public function to_array(): array { return array( 'preset' => $this->preset, 'start' => $this->start, 'end' => $this->end, 'compare_start' => $this->compare_start, 'compare_end' => $this->compare_end, ); } /** * Sets the comparison range to the previous period of equal length, * ending the day before start. */ private function set_compare(): void { $start_ts = (int) self::parse( $this->start ); $end_ts = (int) self::parse( $this->end ); $days = (int) round( ( $end_ts - $start_ts ) / DAY_IN_SECONDS ) + 1; $compare_end = self::add_days( $start_ts, -1 ); $this->compare_end = gmdate( 'Y-m-d', $compare_end ); $this->compare_start = gmdate( 'Y-m-d', self::add_days( $compare_end, -( $days - 1 ) ) ); } /** * The earliest allowed custom start for a given today. * * @param int $today_ts Today, UTC midnight timestamp. */ private static function earliest_ts( int $today_ts ): int { return ( new \DateTimeImmutable( '@' . $today_ts ) )->modify( '-' . self::MAX_YEARS_BACK . ' years' )->getTimestamp(); } /** * The site's current date, Y-m-d (its timezone, not UTC). Exposed to the * client as the date picker's max. */ public static function site_today(): string { return (string) current_time( 'Y-m-d' ); } /** * Strictly parses a Y-m-d date to a UTC midnight timestamp. * * @param string $date Date string. * @return int|null Timestamp, or null when not a real Y-m-d date. */ private static function parse( string $date ): ?int { if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $date ) ) { return null; } $parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date, new \DateTimeZone( 'UTC' ) ); if ( false === $parsed || $parsed->format( 'Y-m-d' ) !== $date ) { return null; } return $parsed->getTimestamp(); } /** * Adds whole days to a UTC midnight timestamp. * * @param int $timestamp UTC midnight timestamp. * @param int $days Days to add (may be negative). */ private static function add_days( int $timestamp, int $days ): int { return $timestamp + $days * DAY_IN_SECONDS; } /** * Shifts a Y-m-d date by whole days. * * @param string $date Y-m-d date. * @param int $days Days to add (may be negative). */ private static function shift( string $date, int $days ): string { return gmdate( 'Y-m-d', self::add_days( (int) self::parse( $date ), $days ) ); } }