Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f62b80c
Pass scheduled timestamp to initiator hook.
zutigrm Mar 11, 2026
0321542
Schedule initiator with timestamp arg.
zutigrm Mar 11, 2026
56c00a9
Use scheduled timestamp as batch anchor.
zutigrm Mar 11, 2026
474bab0
Check initiator existence by frequency.
zutigrm Mar 11, 2026
7e4a157
Update initiator assertions for new args.
zutigrm Mar 11, 2026
3352908
Assert initiator args include scheduled timestamp.
zutigrm Mar 11, 2026
bcec24f
Add timezone midnight scheduling test.
zutigrm Mar 11, 2026
982fed1
Assert worker/fallback use scheduled timestamp.
zutigrm Mar 11, 2026
c071d39
Mock frequency-based initiator checks.
zutigrm Mar 11, 2026
377df77
Merge remote-tracking branch 'origin/develop' into bug/12102-timezone…
zutigrm Mar 16, 2026
2143a0a
Add BC wp_timezone fallback utility.
zutigrm Mar 16, 2026
2c47c48
Use BC timezone.
zutigrm Mar 16, 2026
7556872
Use scheduler helper for initiator timestamp assertions.
zutigrm Mar 16, 2026
bf5faf9
Make initiator test timestamp human-readable.
zutigrm Mar 16, 2026
eb7eca5
Fix date range mismatch - extra day excluded.
zutigrm Mar 16, 2026
a889481
Update tests to verify correct date ranges.
zutigrm Mar 16, 2026
2eeba57
Merge remote-tracking branch 'origin/develop' into bug/12102-timezone…
zutigrm Mar 17, 2026
4da1fce
Add wp_timezone_string method.
zutigrm Mar 17, 2026
b4dcf6a
Add tests.
zutigrm Mar 17, 2026
130fa18
Consistently apply strtotime over hard coded unix timestamp.
benbowler Mar 17, 2026
cd6c4bc
Fix uses of wp_timezone within Frequency_PlannerTest.
benbowler Mar 17, 2026
50da565
Add comment to private function usage.
tofumatt Mar 18, 2026
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
5 changes: 3 additions & 2 deletions includes/Core/Email_Reporting/Email_Log.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
namespace Google\Site_Kit\Core\Email_Reporting;

use Google\Site_Kit\Core\User\Email_Reporting_Settings as Reporting_Settings;
use Google\Site_Kit\Core\Util\BC_Functions;
use Google\Site_Kit\Core\Util\Method_Proxy_Trait;

/**
Expand Down Expand Up @@ -224,8 +225,8 @@ protected static function format_reference_date( $value ) {
return null;
}

if ( function_exists( 'wp_timezone' ) && function_exists( 'wp_date' ) ) {
$timezone = wp_timezone();
if ( function_exists( 'wp_date' ) ) {
$timezone = BC_Functions::wp_timezone();
if ( $timezone ) {
return wp_date( 'Y-m-d', $timestamp, $timezone );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace Google\Site_Kit\Core\Email_Reporting;

use Google\Site_Kit\Core\Util\BC_Functions;

/**
* Helper class to normalize and process report payloads for email sections.
*
Expand Down Expand Up @@ -94,7 +96,7 @@ public function compute_date_range( $date_range ) {
}

// Ensure dates are localized strings (Y-m-d) using site timezone.
$timezone = function_exists( 'wp_timezone' ) ? wp_timezone() : null;
$timezone = BC_Functions::wp_timezone();
if ( function_exists( 'wp_date' ) && $timezone ) {
$start_timestamp = strtotime( $start );
$end_timestamp = strtotime( $end );
Expand Down
2 changes: 1 addition & 1 deletion includes/Core/Email_Reporting/Email_Reporting.php
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public function register() {
$this->scheduler->schedule_monitor();
$this->scheduler->schedule_cleanup();

add_action( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $this->initiator_task, 'handle_callback_action' ), 10, 1 );
add_action( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $this->initiator_task, 'handle_callback_action' ), 10, 2 );
add_action( Email_Reporting_Scheduler::ACTION_MONITOR, array( $this->monitor_task, 'handle_monitor_action' ) );
add_action( Email_Reporting_Scheduler::ACTION_WORKER, array( $this->worker_task, 'handle_callback_action' ), 10, 3 );
add_action( Email_Reporting_Scheduler::ACTION_FALLBACK, array( $this->fallback_task, 'handle_fallback_action' ), 10, 3 );
Expand Down
63 changes: 58 additions & 5 deletions includes/Core/Email_Reporting/Email_Reporting_Scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace Google\Site_Kit\Core\Email_Reporting;

use Google\Site_Kit\Core\Util\BC_Functions;
use Google\Site_Kit\Core\User\Email_Reporting_Settings;

/**
Expand Down Expand Up @@ -73,13 +74,13 @@ public function schedule_initiator_events() {
* @param string $frequency Frequency slug.
*/
public function schedule_initiator_once( $frequency ) {
if ( wp_next_scheduled( self::ACTION_INITIATOR, array( $frequency ) ) ) {
if ( $this->is_initiator_scheduled( $frequency ) ) {
return;
}

$next = $this->frequency_planner->next_occurrence( $frequency, time(), wp_timezone() );
$next = $this->frequency_planner->next_occurrence( $frequency, time(), BC_Functions::wp_timezone() );

wp_schedule_single_event( $next, self::ACTION_INITIATOR, array( $frequency ) );
wp_schedule_single_event( $next, self::ACTION_INITIATOR, array( $frequency, $next ) );
}

/**
Expand All @@ -91,9 +92,61 @@ public function schedule_initiator_once( $frequency ) {
* @param int $timestamp Base timestamp used to calculate the next run.
*/
public function schedule_next_initiator( $frequency, $timestamp ) {
$next = $this->frequency_planner->next_occurrence( $frequency, $timestamp, wp_timezone() );
$next = $this->frequency_planner->next_occurrence( $frequency, $timestamp, BC_Functions::wp_timezone() );

wp_schedule_single_event( $next, self::ACTION_INITIATOR, array( $frequency ) );
wp_schedule_single_event( $next, self::ACTION_INITIATOR, array( $frequency, $next ) );
}

/**
* Checks whether an initiator event exists for the provided frequency.
*
* @since n.e.x.t
*
* @param string $frequency Frequency slug.
* @return bool Whether an initiator event is already scheduled for this frequency.
*/
public function is_initiator_scheduled( $frequency ) {
return false !== $this->get_initiator_timestamp( $frequency );
}

/**
* Gets the timestamp of the next initiator event for a frequency.
*
* We intentionally scan cron entries instead of using `wp_next_scheduled()`
* because initiators are scheduled with dynamic args:
* `[ $frequency, $scheduled_timestamp ]`. `wp_next_scheduled()` requires an
* exact args match, but here we only need to know whether *any* initiator
* exists for a given frequency regardless of its scheduled timestamp.
*
* @since n.e.x.t
*
* @param string $frequency Frequency slug.
* @return int|false Timestamp if found, otherwise false.
*/
public function get_initiator_timestamp( $frequency ) {
$cron = _get_cron_array();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 This is a private function (clearly marked as such with the leading underscore) and is explicitly marked as not being for third-parties to use/rely on: https://developer.wordpress.org/reference/functions/_get_cron_array/

Using private functions like this could cause Site Kit to break unexpectedly in the future—I know we test nightly versions of WordPress right now to check on this sort of thing, but is there any way we can get this data without relying on a private API?

I'd really rather us not use APIs like this, and it wasn't mentioned in the IB.


If we do need to use this, at the absolute minimum, we should thoroughly document our reasoning for using this private API (why public APIs can't provide the data we need).

I can see we have tests for this function so that's good 😄.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We are using it here because of dynamic timestamp argument, so default wp_next_scheduled check won;t do as we don't have static arguments for hook to be correctly identified. The reasoning is explained in detail already in the method header doc:

/**
* Gets the timestamp of the next initiator event for a frequency.
*
* We intentionally scan cron entries instead of using wp_next_scheduled()
* because initiators are scheduled with dynamic args:
* [ $frequency, $scheduled_timestamp ]. wp_next_scheduled() requires an
* exact args match, but here we only need to know whether any initiator
* exists for a given frequency regardless of its scheduled timestamp.
*
* @SInCE n.e.x.t
*
* @param string $frequency Frequency slug.
* @return int|false Timestamp if found, otherwise false.
*/

Similar reason we also use this internal check on uninstallation hook:

private function is_event_scheduled( $hook ) {
$crons = _get_cron_array();
if ( ! is_array( $crons ) || empty( $crons ) ) {
return false;
}
foreach ( $crons as $events ) {
if ( isset( $events[ $hook ] ) ) {
return true;
}
}
return false;


if ( ! is_array( $cron ) ) {
return false;
}

foreach ( $cron as $timestamp => $hooks ) {
if ( empty( $hooks[ self::ACTION_INITIATOR ] ) || ! is_array( $hooks[ self::ACTION_INITIATOR ] ) ) {
continue;
}

foreach ( $hooks[ self::ACTION_INITIATOR ] as $event ) {
$args = isset( $event['args'] ) && is_array( $event['args'] )
? $event['args']
: array();

if ( isset( $args[0] ) && $frequency === $args[0] ) {
return (int) $timestamp;
}
}
}

return false;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion includes/Core/Email_Reporting/Email_Template_Formatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Google\Site_Kit\Context;
use Google\Site_Kit\Core\Golinks\Golinks;
use Google\Site_Kit\Core\Email_Reporting\Notices\Enable_Conversion_Events_Email_Notice;
use Google\Site_Kit\Core\Util\BC_Functions;
use Google\Site_Kit\Core\User\Email_Reporting_Settings;
use WP_Error;
use WP_Post;
Expand Down Expand Up @@ -537,7 +538,7 @@ private function build_date_label( array $date_range ) {
return $value;
}

$timezone = function_exists( 'wp_timezone' ) ? wp_timezone() : null;
$timezone = BC_Functions::wp_timezone();
if ( $timezone && function_exists( 'wp_date' ) ) {
return wp_date( 'M j', $timestamp, $timezone );
}
Expand Down
20 changes: 15 additions & 5 deletions includes/Core/Email_Reporting/Initiator_Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use DateInterval;
use DateTimeImmutable;
use Google\Site_Kit\Core\Util\BC_Functions;
use Google\Site_Kit\Core\User\Email_Reporting_Settings;

/**
Expand Down Expand Up @@ -55,10 +56,15 @@ public function __construct( Email_Reporting_Scheduler $scheduler, Subscribed_Us
*
* @since 1.167.0
*
* @param string $frequency Frequency slug.
* @param string $frequency Frequency slug.
* @param int|null $scheduled_timestamp Scheduled initiator timestamp.
*/
public function handle_callback_action( $frequency ) {
$timestamp = time();
public function handle_callback_action( $frequency, $scheduled_timestamp = null ) {
$timestamp = (int) $scheduled_timestamp;

if ( $timestamp <= 0 ) {
$timestamp = time();
}

$this->scheduler->schedule_next_initiator( $frequency, $timestamp );

Expand Down Expand Up @@ -101,7 +107,7 @@ public function handle_callback_action( $frequency ) {
* @return array Reference date payload.
*/
public static function build_reference_dates( $frequency, $timestamp ) {
$time_zone = wp_timezone();
$time_zone = BC_Functions::wp_timezone();
$send_date = ( new DateTimeImmutable( '@' . $timestamp ) )
->setTimezone( $time_zone )
->setTime( 0, 0, 0 );
Expand All @@ -114,7 +120,11 @@ public static function build_reference_dates( $frequency, $timestamp ) {

$period_days = isset( $period_lengths[ $frequency ] ) ? $period_lengths[ $frequency ] : $period_lengths[ Email_Reporting_Settings::FREQUENCY_WEEKLY ];

$start_date = $send_date->sub( new DateInterval( sprintf( 'P%dD', $period_days ) ) );
// Keep the period length inclusive of sendDate (e.g. weekly = 7 days total).
// Subtracting period_days would produce an 8-day window.
$start_date = $send_date->sub(
new DateInterval( sprintf( 'P%dD', max( $period_days - 1, 0 ) ) )
);
$compare_end_date = $start_date->sub( new DateInterval( 'P1D' ) );
$compare_start_date = $compare_end_date->sub(
new DateInterval( sprintf( 'P%dD', max( $period_days - 1, 0 ) ) )
Expand Down
2 changes: 1 addition & 1 deletion includes/Core/Email_Reporting/Monitor_Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function handle_monitor_action() {
}

foreach ( array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY, User_Email_Reporting_Settings::FREQUENCY_MONTHLY, User_Email_Reporting_Settings::FREQUENCY_QUARTERLY ) as $frequency ) {
if ( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $frequency ) ) ) {
if ( $this->scheduler->is_initiator_scheduled( $frequency ) ) {
continue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace Google\Site_Kit\Core\Email_Reporting;

use Google\Site_Kit\Core\Util\BC_Functions;
use Google\Site_Kit\Core\User\Email_Reporting_Settings;

/**
Expand Down Expand Up @@ -95,7 +96,7 @@ public function schedule( $user_id, $frequency ) {
$first_report_timestamp = $this->frequency_planner->next_occurrence(
$frequency,
$timestamp,
wp_timezone()
BC_Functions::wp_timezone()
);
$reference_dates = Initiator_Task::build_reference_dates( $frequency, $first_report_timestamp );

Expand Down
39 changes: 39 additions & 0 deletions includes/Core/Util/BC_Functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
* @method static void wp_print_script_tag( $attributes )
* @method static void wp_print_inline_script_tag( $javascript, $attributes = array() )
* @method static bool array_is_list( array $value )
* @method static string wp_timezone_string()
* @method static \DateTimeZone wp_timezone()
*/
class BC_Functions {

Expand Down Expand Up @@ -151,6 +153,43 @@ protected static function wp_get_sidebar( $id ) {
return null;
}

/**
* A fallback for the wp_timezone_string function introduced in WordPress 5.3.0.
*
* @since n.e.x.t
*
* @return string PHP timezone string or a ±HH:MM offset.
*/
protected static function wp_timezone_string() {
$timezone_string = get_option( 'timezone_string' );

if ( $timezone_string ) {
return $timezone_string;
}

$offset = (float) get_option( 'gmt_offset' );
$hours = (int) $offset;
$minutes = ( $offset - $hours );

$sign = ( $offset < 0 ) ? '-' : '+';
$abs_hour = abs( $hours );
$abs_mins = abs( $minutes * 60 );
$tz_offset = sprintf( '%s%02d:%02d', $sign, $abs_hour, $abs_mins );

return $tz_offset;
}

/**
* A fallback for the wp_timezone function introduced in WordPress 5.3.0.
*
* @since n.e.x.t
*
* @return \DateTimeZone Site timezone.
*/
protected static function wp_timezone() {
return new \DateTimeZone( self::wp_timezone_string() );
}

/**
* A fallback for the array_is_list function introduced in PHP 8.1.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Google\Site_Kit\Core\Email_Reporting\Email_Log;
use Google\Site_Kit\Core\Email_Reporting\Email_Reporting;
use Google\Site_Kit\Core\Email_Reporting\Email_Reporting_Scheduler;
use Google\Site_Kit\Core\Email_Reporting\Frequency_Planner;
use Google\Site_Kit\Core\Email_Reporting\Email_Reporting_Settings;
use Google\Site_Kit\Core\User\Email_Reporting_Settings as User_Email_Reporting_Settings;
use Google\Site_Kit\Core\Email_Reporting\Email_Reporting_Data_Requests;
Expand Down Expand Up @@ -155,7 +156,7 @@ public function test_register_schedules_initiators_when_enabled() {

foreach ( array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY, User_Email_Reporting_Settings::FREQUENCY_MONTHLY, User_Email_Reporting_Settings::FREQUENCY_QUARTERLY ) as $frequency ) {
$this->assertNotFalse(
wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $frequency ) ),
$this->get_initiator_scheduled_timestamp( $frequency ),
sprintf( 'Expected initiator to be scheduled for frequency %s.', $frequency )
);
}
Expand All @@ -179,7 +180,7 @@ public function test_disabling_unschedules_all_events() {

foreach ( array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY, User_Email_Reporting_Settings::FREQUENCY_MONTHLY, User_Email_Reporting_Settings::FREQUENCY_QUARTERLY ) as $frequency ) {
$this->assertFalse(
wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $frequency ) ),
$this->get_initiator_scheduled_timestamp( $frequency ),
sprintf( 'Initiator should be unscheduled for frequency %s when reporting disabled.', $frequency )
);
}
Expand All @@ -198,7 +199,8 @@ public function test_register_clears_existing_events_when_disabled() {
$settings = new Email_Reporting_Settings( $this->options );
$settings->set( array( 'enabled' => false ) );

wp_schedule_single_event( time() + 50, Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ) );
$initiator_timestamp = time() + 50;
wp_schedule_single_event( $initiator_timestamp, Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY, $initiator_timestamp ) );
$worker_timestamp = time();
wp_schedule_single_event( time() + 50, Email_Reporting_Scheduler::ACTION_WORKER, array( 'batch', User_Email_Reporting_Settings::FREQUENCY_WEEKLY, $worker_timestamp ) );
$fallback_timestamp = time();
Expand All @@ -209,7 +211,7 @@ public function test_register_clears_existing_events_when_disabled() {
$email_reporting = $this->create_email_reporting();
$email_reporting->register();

$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ) ), 'Initiator event should be cleared when reporting is disabled.' );
$this->assertFalse( $this->get_initiator_scheduled_timestamp( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ), 'Initiator event should be cleared when reporting is disabled.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_WORKER, array( 'batch', User_Email_Reporting_Settings::FREQUENCY_WEEKLY, $worker_timestamp ) ), 'Worker event should be cleared when reporting is disabled.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_FALLBACK, array( 'batch', User_Email_Reporting_Settings::FREQUENCY_WEEKLY, $fallback_timestamp ) ), 'Fallback event should be cleared when reporting is disabled.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_MONITOR ), 'Monitor event should be cleared when reporting is disabled.' );
Expand All @@ -221,16 +223,17 @@ public function test_register_unschedules_when_setup_incomplete() {
remove_filter( 'googlesitekit_setup_complete', '__return_true' );
delete_option( Credentials::OPTION );

wp_schedule_single_event( time() + 50, Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ) );
$initiator_timestamp = time() + 50;
wp_schedule_single_event( $initiator_timestamp, Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY, $initiator_timestamp ) );
wp_schedule_event( time() + 50, 'daily', Email_Reporting_Scheduler::ACTION_MONITOR );
wp_schedule_event( time() + 50, 'daily', Email_Reporting_Scheduler::ACTION_CLEANUP );

$email_reporting = $this->create_email_reporting();
$email_reporting->register();

$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ) ), 'Initiator should be unscheduled when setup is incomplete.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_MONTHLY ) ), 'Monthly initiator should not be scheduled when setup is incomplete.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_INITIATOR, array( User_Email_Reporting_Settings::FREQUENCY_QUARTERLY ) ), 'Quarterly initiator should not be scheduled when setup is incomplete.' );
$this->assertFalse( $this->get_initiator_scheduled_timestamp( User_Email_Reporting_Settings::FREQUENCY_WEEKLY ), 'Initiator should be unscheduled when setup is incomplete.' );
$this->assertFalse( $this->get_initiator_scheduled_timestamp( User_Email_Reporting_Settings::FREQUENCY_MONTHLY ), 'Monthly initiator should not be scheduled when setup is incomplete.' );
$this->assertFalse( $this->get_initiator_scheduled_timestamp( User_Email_Reporting_Settings::FREQUENCY_QUARTERLY ), 'Quarterly initiator should not be scheduled when setup is incomplete.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_MONITOR ), 'Monitor should not be scheduled when setup is incomplete.' );
$this->assertFalse( wp_next_scheduled( Email_Reporting_Scheduler::ACTION_CLEANUP ), 'Cleanup should not be scheduled when setup is incomplete.' );
}
Expand Down Expand Up @@ -258,6 +261,11 @@ private function create_email_reporting() {
);
}

private function get_initiator_scheduled_timestamp( $frequency ) {
$scheduler = new Email_Reporting_Scheduler( new Frequency_Planner() );
return $scheduler->get_initiator_timestamp( $frequency );
}

private function clear_scheduled_events() {
foreach ( array( Email_Reporting_Scheduler::ACTION_INITIATOR, Email_Reporting_Scheduler::ACTION_WORKER, Email_Reporting_Scheduler::ACTION_FALLBACK, Email_Reporting_Scheduler::ACTION_MONITOR, Email_Reporting_Scheduler::ACTION_CLEANUP ) as $hook ) {
wp_unschedule_hook( $hook );
Expand Down
Loading
Loading