diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php
index f446d35..866c67c 100644
--- a/class-wp-press-this-plugin.php
+++ b/class-wp-press-this-plugin.php
@@ -1550,6 +1550,8 @@ public function html() {
'postId' => $post_ID,
'title' => $post_title,
'content' => $post_content,
+ 'postStatus' => $post->post_status,
+ 'postDate' => mysql_to_rfc3339( $post->post_date ),
'nonce' => wp_create_nonce( 'update-post_' . $post_ID ),
'categoryNonce' => wp_create_nonce( 'add-category' ),
@@ -1613,6 +1615,7 @@ public function html() {
// Config.
'redirInParent' => $site_settings['redirInParent'],
'isRTL' => is_rtl(),
+ 'timezone' => wp_timezone_string(),
// Allowed blocks.
'allowedBlocks' => $this->get_allowed_blocks(),
diff --git a/includes/class-press-this-assets.php b/includes/class-press-this-assets.php
index 643c900..7fe27b1 100644
--- a/includes/class-press-this-assets.php
+++ b/includes/class-press-this-assets.php
@@ -230,6 +230,7 @@ public function get_script_dependencies() {
'wp-format-library',
'wp-html-entities',
'wp-i18n',
+ 'wp-keyboard-shortcuts',
'wp-primitives',
);
}
diff --git a/press-this-plugin.php b/press-this-plugin.php
index 66363ec..ac4fce8 100644
--- a/press-this-plugin.php
+++ b/press-this-plugin.php
@@ -184,7 +184,25 @@ function press_this_register_rest_routes() {
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'draft',
- 'enum' => array( 'draft', 'publish' ),
+ 'enum' => array( 'draft', 'publish', 'future' ),
+ ),
+ 'date' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => function ( $value ) {
+ if ( empty( $value ) ) {
+ return true;
+ }
+ if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/', $value ) ) {
+ return new WP_Error(
+ 'press_this_invalid_date_format',
+ __( 'Date must be in ISO 8601 format.', 'press-this' ),
+ array( 'status' => 400 )
+ );
+ }
+ return true;
+ },
+ 'default' => '',
),
'format' => array(
'type' => 'string',
@@ -342,6 +360,31 @@ function press_this_rest_save_post( $request ) {
}
}
+ // Handle future (scheduled) status.
+ if ( 'future' === $status ) {
+ if ( current_user_can( 'publish_posts' ) ) {
+ $date = $request->get_param( 'date' );
+ if ( empty( $date ) || false === strtotime( $date ) ) {
+ return new WP_Error(
+ 'press_this_invalid_date',
+ __( 'A valid date is required to schedule a post.', 'press-this' ),
+ array( 'status' => 400 )
+ );
+ }
+ // The frontend sends a naive datetime in the site's local timezone (no TZ qualifier).
+ // WordPress sets PHP's timezone to UTC, so strtotime() interprets the string as UTC
+ // and gmdate() formats it back as UTC -- the round-trip preserves the original value.
+ // The result is the site-local time string we need for post_date.
+ $post_data['post_date'] = gmdate( 'Y-m-d H:i:s', strtotime( $date ) );
+ $post_data['post_date_gmt'] = get_gmt_from_date( $post_data['post_date'] );
+ $post_data['post_status'] = 'future';
+ // Required: wp_update_post ignores post_date changes unless edit_date is true.
+ $post_data['edit_date'] = true;
+ } else {
+ $post_data['post_status'] = 'pending';
+ }
+ }
+
// Side-load images from content.
// Require admin includes needed by media_sideload_image() (not loaded in REST context).
require_once ABSPATH . 'wp-admin/includes/file.php';
diff --git a/src/App.js b/src/App.js
index 85f4a2d..3c5b34a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -47,6 +47,10 @@ export default function App() {
publishLabel: __( 'Publish', 'press-this' ),
} );
+ // Post status and date as React state so they update after scheduling.
+ const [ postStatus, setPostStatus ] = useState( () => data.postStatus || '' );
+ const [ postDate, setPostDate ] = useState( () => data.postDate || '' );
+
// Build initial post object for editor.
const post = useMemo(
() => ( {
@@ -316,6 +320,21 @@ export default function App() {
setSaveState( state );
}, [] );
+ /**
+ * Handle post status changes from PressThisEditor after a successful save.
+ * Updates local state so the Header reflects the new status (e.g., "Reschedule").
+ *
+ * @param {Object} change Status change details.
+ * @param {string} change.status New post status.
+ * @param {string} change.date New post date (ISO 8601).
+ */
+ const handlePostStatusChange = useCallback( ( change ) => {
+ setPostStatus( change.status );
+ if ( change.date ) {
+ setPostDate( change.date );
+ }
+ }, [] );
+
// State for undo/redo from editor.
// Split into primitive values so React can skip re-renders when values
// haven't changed (Object.is comparison). The handlers are stable refs
@@ -360,6 +379,10 @@ export default function App() {
onRedo={ redoHandler }
hasUndo={ hasUndo }
hasRedo={ hasRedo }
+ capabilities={ capabilities }
+ timezone={ data.timezone }
+ postStatus={ postStatus }
+ postDate={ postDate }
/>
@@ -377,6 +400,8 @@ export default function App() {
onScrapeProcessed={ handleScrapeProcessed }
onSaveReady={ handleSaveReady }
onUndoReady={ handleUndoReady }
+ timezone={ data.timezone }
+ onPostStatusChange={ handlePostStatusChange }
categoryNonce={ data.categoryNonce || '' }
ajaxUrl={ data.ajaxUrl || '' }
/>
diff --git a/src/components/Header.js b/src/components/Header.js
index 89cee34..5c488eb 100644
--- a/src/components/Header.js
+++ b/src/components/Header.js
@@ -20,6 +20,8 @@ import {
DropdownMenu,
MenuGroup,
MenuItem,
+ Popover,
+ DateTimePicker,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import {
@@ -33,6 +35,8 @@ import {
*/
import { buildSuggestedContentFromMetadata } from '../utils';
+const ONE_MINUTE = 60 * 1000;
+
/**
* Detect if running on macOS for keyboard shortcut hints.
*
@@ -45,6 +49,146 @@ function isMacOS() {
);
}
+/**
+ * Derive a short timezone abbreviation from a timezone string.
+ *
+ * @param {string} tz Timezone string (e.g., 'America/New_York' or 'UTC+5').
+ * @param {string} date ISO date string to derive abbreviation for (for DST accuracy).
+ * @return {string} Timezone abbreviation (e.g., 'EST' or 'UTC+5').
+ */
+function getTimezoneAbbreviation( tz, date ) {
+ if ( ! tz ) {
+ return '';
+ }
+
+ // Fixed offset timezones pass through directly.
+ if ( /^UTC[+-]?\d*$/.test( tz ) ) {
+ return tz;
+ }
+
+ try {
+ const formatter = new Intl.DateTimeFormat( 'en-US', {
+ timeZone: tz,
+ timeZoneName: 'short',
+ } );
+ // Parse date parts to build a UTC instant matching the wall-clock time,
+ // so DST lookup is correct for the selected date, not the browser's interpretation.
+ let refDate = new Date();
+ if ( date ) {
+ const m = date.match(
+ /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/
+ );
+ if ( m ) {
+ refDate = new Date(
+ Date.UTC(
+ Number( m[ 1 ] ),
+ Number( m[ 2 ] ) - 1,
+ Number( m[ 3 ] ),
+ Number( m[ 4 ] ),
+ Number( m[ 5 ] )
+ )
+ );
+ }
+ }
+ const parts = formatter.formatToParts( refDate );
+ const tzPart = parts.find( ( p ) => p.type === 'timeZoneName' );
+ return tzPart ? tzPart.value : tz;
+ } catch {
+ return tz;
+ }
+}
+
+/**
+ * Get the current date/time as an ISO string in the site timezone.
+ *
+ * @param {string} tz Timezone string.
+ * @return {string} ISO 8601 date string.
+ */
+function getCurrentDateInTimezone( tz ) {
+ const pad = ( n ) => String( n ).padStart( 2, '0' );
+ const formatNaive = ( d ) =>
+ `${ d.getUTCFullYear() }-${ pad( d.getUTCMonth() + 1 ) }-${ pad(
+ d.getUTCDate()
+ ) }T${ pad( d.getUTCHours() ) }:${ pad( d.getUTCMinutes() ) }:${ pad(
+ d.getUTCSeconds()
+ ) }`;
+
+ if ( ! tz ) {
+ return formatNaive( new Date() );
+ }
+
+ // Handle fixed-offset timezones (e.g. "UTC+2", "UTC-10") which
+ // Intl.DateTimeFormat does not accept.
+ const offsetMatch = tz.match( /^UTC([+-]\d+(?:\.\d+)?)$/ );
+ if ( offsetMatch ) {
+ const offsetHours = parseFloat( offsetMatch[ 1 ] );
+ const now = new Date();
+ // Shift UTC time by the fixed offset to get wall-clock time.
+ return formatNaive( new Date( now.getTime() + offsetHours * 3600000 ) );
+ }
+
+ try {
+ const now = new Date();
+ const formatter = new Intl.DateTimeFormat( 'en-CA', {
+ timeZone: tz,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ } );
+ const parts = formatter.formatToParts( now );
+ const get = ( type ) =>
+ parts.find( ( p ) => p.type === type )?.value || '';
+ return `${ get( 'year' ) }-${ get( 'month' ) }-${ get( 'day' ) }T${ get(
+ 'hour'
+ ) }:${ get( 'minute' ) }:${ get( 'second' ) }`;
+ } catch {
+ return formatNaive( new Date() );
+ }
+}
+
+/**
+ * Parse a naive datetime string to UTC milliseconds by extracting parts directly,
+ * avoiding browser-local timezone interpretation via new Date().
+ *
+ * @param {string} dateString Naive ISO date string (e.g., "2026-03-07T15:00:00").
+ * @return {number} Milliseconds (as if the wall-clock time were in UTC).
+ */
+function parseNaiveToMs( dateString ) {
+ const m = dateString.match( /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/ );
+ if ( ! m ) {
+ return new Date( dateString ).getTime();
+ }
+ return Date.UTC(
+ Number( m[ 1 ] ),
+ Number( m[ 2 ] ) - 1,
+ Number( m[ 3 ] ),
+ Number( m[ 4 ] ),
+ Number( m[ 5 ] )
+ );
+}
+
+/**
+ * Check whether a date string represents a future date using a 1-minute buffer.
+ * Matches Gutenberg's isEditedPostBeingScheduled pattern.
+ *
+ * Both dateString and the current time are parsed as wall-clock parts via
+ * parseNaiveToMs to avoid browser-local timezone reinterpretation at DST boundaries.
+ *
+ * @param {string} dateString ISO date string to check (naive, in site timezone).
+ * @param {string} tz Site timezone string.
+ * @return {boolean} True if the date is more than 1 minute in the future.
+ */
+function isFutureDate( dateString, tz ) {
+ const selectedMs = parseNaiveToMs( dateString );
+ const nowInSiteTz = getCurrentDateInTimezone( tz );
+ const nowMs = parseNaiveToMs( nowInSiteTz );
+ return selectedMs - nowMs > ONE_MINUTE;
+}
+
/**
* Header component.
*
@@ -66,8 +210,19 @@ function isMacOS() {
* @param {Function} props.onRedo Redo callback.
* @param {boolean} props.hasUndo Whether undo is available.
* @param {boolean} props.hasRedo Whether redo is available.
+ * @param {Object} props.capabilities User capabilities.
+ * @param {string} props.timezone Site timezone string from wp_timezone_string().
+ * @param {string} props.postStatus Current post status.
+ * @param {string} props.postDate Current post date (ISO 8601).
* @return {JSX.Element} Header component.
*/
+export {
+ getTimezoneAbbreviation,
+ getCurrentDateInTimezone,
+ parseNaiveToMs,
+ isFutureDate,
+};
+
export default function Header( {
siteName,
siteUrl,
@@ -86,6 +241,10 @@ export default function Header( {
onRedo,
hasUndo = false,
hasRedo = false,
+ capabilities = {},
+ timezone = '',
+ postStatus = '',
+ postDate = '',
} ) {
const [ scanUrl, setScanUrl ] = useState( sourceUrl || '' );
const [ isScanning, setIsScanning ] = useState( false );
@@ -93,13 +252,39 @@ export default function Header( {
const [ showUpgradeNotice, setShowUpgradeNotice ] =
useState( isLegacyBookmarklet );
+ // Schedule popover state.
+ const [ isScheduleOpen, setIsScheduleOpen ] = useState( false );
+ const [ scheduleDate, setScheduleDate ] = useState( () => {
+ if ( postStatus === 'future' && postDate ) {
+ return postDate;
+ }
+ return getCurrentDateInTimezone( timezone );
+ } );
+
// Track if initial auto-scan has been performed.
const hasAutoScanned = useRef( false );
+ // Ref for anchoring the schedule popover near the More actions button.
+ const moreMenuRef = useRef( null );
+
// Keyboard shortcut hints based on platform.
const undoShortcut = isMacOS() ? '\u2318Z' : 'Ctrl+Z';
const redoShortcut = isMacOS() ? '\u21E7\u2318Z' : 'Ctrl+Shift+Z';
+ const timezoneAbbreviation = getTimezoneAbbreviation(
+ timezone,
+ scheduleDate
+ );
+ const isScheduleFuture = isFutureDate( scheduleDate, timezone );
+ const scheduleButtonLabel = isScheduleFuture
+ ? __( 'Schedule', 'press-this' )
+ : __( 'Publish', 'press-this' );
+
+ const scheduleMenuLabel =
+ postStatus === 'future'
+ ? __( 'Reschedule', 'press-this' )
+ : __( 'Schedule', 'press-this' );
+
/**
* Handle URL scan via proxy API.
*
@@ -277,6 +462,25 @@ export default function Header( {
}
}, [ onSave ] );
+ /**
+ * Handle schedule confirmation.
+ * Calls onSave with 'future' status and date for future dates,
+ * or 'publish' for past dates.
+ */
+ const handleScheduleConfirm = useCallback( () => {
+ if ( ! onSave ) {
+ return;
+ }
+
+ if ( isFutureDate( scheduleDate, timezone ) ) {
+ onSave( 'future', { date: scheduleDate } );
+ } else {
+ onSave( 'publish' );
+ }
+
+ setIsScheduleOpen( false );
+ }, [ onSave, scheduleDate, timezone ] );
+
// Only show scanner when proxy is enabled.
// Without proxy, server-side scraping doesn't work - the scanner would be non-functional.
// Bookmarklet flow works without proxy (client-side scraping) but doesn't need the scanner UI.
@@ -386,27 +590,87 @@ export default function Header( {
>
{ publishLabel }
-
- { ( { onClose } ) => (
-
-
) }
diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js
index 143a3fc..8412484 100644
--- a/src/components/PressThisEditor.js
+++ b/src/components/PressThisEditor.js
@@ -41,7 +41,7 @@ import {
PanelBody,
FormTokenField,
} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { registerCoreBlocks } from '@wordpress/block-library';
/**
@@ -228,27 +228,83 @@ function getWpRestBaseUrl( pressThisRestUrl ) {
return pressThisRestUrl.replace( /press-this\/v1\/$/, '' );
}
+/**
+ * Format a date string for display in the schedule snackbar.
+ *
+ * Uses the browser's default locale for formatting so the date is displayed
+ * in the user's preferred language rather than hardcoded to English.
+ *
+ * @param {string} dateString ISO date string to format.
+ * @param {string} timezone Timezone identifier (e.g., "America/New_York" or "UTC+2").
+ * @return {string} Human-readable formatted date.
+ */
+function formatScheduleDate( dateString, timezone ) {
+ try {
+ // Parse date parts directly to avoid browser-timezone reinterpretation.
+ // dateString is a naive site-timezone string like "2026-03-15T15:00:00".
+ const match = dateString.match(
+ /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/
+ );
+ if ( ! match ) {
+ return dateString;
+ }
+ const [ , year, month, day, hour, minute ] = match;
+ // Build a UTC Date representing the wall-clock time, then format in UTC.
+ const utcDate = new Date(
+ Date.UTC(
+ Number( year ),
+ Number( month ) - 1,
+ Number( day ),
+ Number( hour ),
+ Number( minute )
+ )
+ );
+ const options = {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZone: 'UTC',
+ };
+ const formatted = new Intl.DateTimeFormat( undefined, options ).format(
+ utcDate
+ );
+ // Append timezone identifier if available.
+ if ( timezone ) {
+ return `${ formatted } ${ timezone }`;
+ }
+ return formatted;
+ } catch {
+ return dateString;
+ }
+}
+
/**
* Press This Editor component.
*
- * @param {Object} props Component props.
- * @param {Object} props.post Post object with ID, title, content.
- * @param {Object} props.settings Editor settings.
- * @param {Array} props.images Scraped images from source.
- * @param {Array} props.embeds Scraped embeds from source.
- * @param {Object} props.categories Available categories.
- * @param {Array} props.postFormats Available post formats.
- * @param {Object} props.capabilities User capabilities.
- * @param {Object} props.restConfig REST API configuration.
- * @param {string} props.sourceUrl Source URL being clipped.
- * @param {Object} props.pendingScrape Pending scraped content to append.
- * @param {Function} props.onScrapeProcessed Callback after scrape is processed.
- * @param {Function} props.onSaveReady Callback when save handler is ready (receives { handleSave, isSaving, publishLabel }).
- * @param {Function} props.onUndoReady Callback when undo/redo handlers are ready (receives { handleUndo, handleRedo, hasUndo, hasRedo }).
+ * @param {Object} props Component props.
+ * @param {Object} props.post Post object with ID, title, content.
+ * @param {Object} props.settings Editor settings.
+ * @param {Array} props.images Scraped images from source.
+ * @param {Array} props.embeds Scraped embeds from source.
+ * @param {Object} props.categories Available categories.
+ * @param {Array} props.postFormats Available post formats.
+ * @param {Object} props.capabilities User capabilities.
+ * @param {Object} props.restConfig REST API configuration.
+ * @param {string} props.sourceUrl Source URL being clipped.
+ * @param {Object} props.pendingScrape Pending scraped content to append.
+ * @param {Function} props.onScrapeProcessed Callback after scrape is processed.
+ * @param {Function} props.onSaveReady Callback when save handler is ready (receives { handleSave, isSaving, publishLabel }).
+ * @param {Function} props.onUndoReady Callback when undo/redo handlers are ready (receives { handleUndo, handleRedo, hasUndo, hasRedo }).
+ * @param {string} props.timezone Site timezone string from wp_timezone_string().
+ * @param {Function} props.onPostStatusChange Callback when post status changes after save (receives { status, date }).
* @param {string} props.categoryNonce
* @param {string} props.ajaxUrl
* @return {JSX.Element} Press This Editor component.
*/
+export { formatScheduleDate };
+
export default function PressThisEditor( {
post,
settings,
@@ -263,6 +319,8 @@ export default function PressThisEditor( {
onScrapeProcessed = () => {},
onSaveReady = () => {},
onUndoReady = () => {},
+ timezone = '',
+ onPostStatusChange = () => {},
categoryNonce = '',
ajaxUrl = '',
} ) {
@@ -472,7 +530,7 @@ export default function PressThisEditor( {
/**
* Handle save operation.
*
- * @param {string} status Post status (draft, publish).
+ * @param {string} status Post status (draft, publish, future).
* @param {Object} options Save options.
*/
const handleSave = useCallback(
@@ -499,6 +557,7 @@ export default function PressThisEditor( {
tags,
featured_image: featuredImageId,
force_redirect: options.forceRedirect || false,
+ date: options.date || '',
} ),
} );
@@ -517,6 +576,26 @@ export default function PressThisEditor( {
} else {
performSafeRedirect( result.redirect, false );
}
+ } else if ( status === 'future' && options.date ) {
+ // Scheduled post -- show formatted date in snackbar.
+ const formatted = formatScheduleDate(
+ options.date,
+ timezone
+ );
+ setNotice( {
+ status: 'success',
+ message: sprintf(
+ /* translators: %s: formatted date and time */
+ __( 'Post scheduled for %s.', 'press-this' ),
+ formatted
+ ),
+ } );
+
+ // Notify parent that post status has changed.
+ onPostStatusChange( {
+ status: 'future',
+ date: options.date,
+ } );
} else {
// No redirect - show success notice.
setNotice( {
@@ -550,6 +629,8 @@ export default function PressThisEditor( {
featuredImageId,
post.id,
restConfig,
+ timezone,
+ onPostStatusChange,
]
);
diff --git a/src/styles/main.scss b/src/styles/main.scss
index 5742d1e..da8b218 100644
--- a/src/styles/main.scss
+++ b/src/styles/main.scss
@@ -32,6 +32,9 @@
// WordPress component overrides.
@use "partials/wordpress-overrides";
+// Header schedule popover.
+@use "partials/header-schedule";
+
// ==========================================================================
// WordPress Block Editor Styles
// ==========================================================================
diff --git a/src/styles/partials/_header-schedule.scss b/src/styles/partials/_header-schedule.scss
new file mode 100644
index 0000000..6ca7145
--- /dev/null
+++ b/src/styles/partials/_header-schedule.scss
@@ -0,0 +1,64 @@
+/**
+ * Header Schedule Popover Styles
+ *
+ * Styles for the schedule DateTimePicker popover in the header.
+ *
+ * @package press-this
+ */
+
+@use "variables" as *;
+
+// ==========================================================================
+// Popover Container
+// ==========================================================================
+
+.press-this-header__schedule-popover {
+ z-index: $z-index-popover;
+
+ .components-popover__content {
+ min-width: 270px;
+ max-width: 320px;
+ }
+}
+
+// ==========================================================================
+// Popover Content
+// ==========================================================================
+
+.press-this-header__schedule-popover-content {
+ padding: $spacing-lg;
+}
+
+// ==========================================================================
+// Timezone Label
+// ==========================================================================
+
+.press-this-header__schedule-timezone {
+ margin: $spacing-sm 0 0;
+ font-size: $font-size-xs;
+ color: $color-text-muted;
+ text-align: center;
+}
+
+// ==========================================================================
+// Confirmation Button
+// ==========================================================================
+
+.press-this-header__schedule-confirm {
+ width: 100%;
+ justify-content: center;
+ margin-top: $spacing-lg;
+}
+
+// ==========================================================================
+// Mobile Adjustments
+// ==========================================================================
+
+@media (max-width: $breakpoint-mobile) {
+ .press-this-header__schedule-popover {
+ .components-popover__content {
+ min-width: 240px;
+ max-width: calc(100vw - #{$spacing-xl * 2});
+ }
+ }
+}
diff --git a/tests/components/header-schedule-ui.test.js b/tests/components/header-schedule-ui.test.js
new file mode 100644
index 0000000..02881c5
--- /dev/null
+++ b/tests/components/header-schedule-ui.test.js
@@ -0,0 +1,150 @@
+/**
+ * Header Schedule UI Tests
+ *
+ * Behavioral tests for the scheduling utility functions in the Header component:
+ * timezone abbreviation derivation, current-date-in-timezone formatting,
+ * naive datetime parsing, and future-date detection.
+ *
+ * @package press-this
+ */
+
+import {
+ getTimezoneAbbreviation,
+ getCurrentDateInTimezone,
+ parseNaiveToMs,
+ isFutureDate,
+} from '../../src/components/Header';
+
+// Mock @wordpress/element so the module can load without React.
+jest.mock( '@wordpress/element', () => ( {
+ useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ],
+ useCallback: ( fn ) => fn,
+ useEffect: jest.fn(),
+ useRef: ( val ) => ( { current: val } ),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ Button: 'Button',
+ TextControl: 'TextControl',
+ Notice: 'Notice',
+ Tooltip: 'Tooltip',
+ DropdownMenu: 'DropdownMenu',
+ MenuGroup: 'MenuGroup',
+ MenuItem: 'MenuItem',
+ Popover: 'Popover',
+ DateTimePicker: 'DateTimePicker',
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: ( text ) => text,
+} ) );
+
+jest.mock( '@wordpress/icons', () => ( {
+ undo: 'undo-icon',
+ redo: 'redo-icon',
+ moreVertical: 'more-vertical-icon',
+} ) );
+
+describe( 'getTimezoneAbbreviation', () => {
+ test( 'returns empty string for falsy timezone', () => {
+ expect( getTimezoneAbbreviation( '', null ) ).toBe( '' );
+ expect( getTimezoneAbbreviation( null, null ) ).toBe( '' );
+ expect( getTimezoneAbbreviation( undefined, null ) ).toBe( '' );
+ } );
+
+ test( 'returns fixed-offset timezones unchanged', () => {
+ expect( getTimezoneAbbreviation( 'UTC', null ) ).toBe( 'UTC' );
+ expect( getTimezoneAbbreviation( 'UTC+5', null ) ).toBe( 'UTC+5' );
+ expect( getTimezoneAbbreviation( 'UTC-10', null ) ).toBe( 'UTC-10' );
+ } );
+
+ test( 'returns a short abbreviation for IANA timezones', () => {
+ const abbr = getTimezoneAbbreviation( 'America/New_York', '2026-01-15T12:00:00' );
+ // In January, New York is in Eastern Standard Time.
+ expect( abbr ).toMatch( /^(EST|GMT-5|UTC-5)/ );
+ } );
+
+ test( 'returns DST-aware abbreviation when date is in summer', () => {
+ const abbr = getTimezoneAbbreviation( 'America/New_York', '2026-07-15T12:00:00' );
+ // In July, New York is in Eastern Daylight Time.
+ expect( abbr ).toMatch( /^(EDT|GMT-4|UTC-4)/ );
+ } );
+
+ test( 'returns the timezone string on invalid IANA identifier', () => {
+ expect( getTimezoneAbbreviation( 'Invalid/Zone', '2026-01-15T12:00:00' ) ).toBe( 'Invalid/Zone' );
+ } );
+} );
+
+describe( 'getCurrentDateInTimezone', () => {
+ test( 'returns an ISO-like string with T separator', () => {
+ const result = getCurrentDateInTimezone( 'America/New_York' );
+ expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ );
+ } );
+
+ test( 'returns a string that differs from UTC for non-UTC timezones', () => {
+ // This is a loose check -- we just verify the function runs without error
+ // and returns a properly formatted string.
+ const result = getCurrentDateInTimezone( 'Asia/Tokyo' );
+ expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ );
+ } );
+
+ test( 'falls back to ISO string for empty timezone', () => {
+ const result = getCurrentDateInTimezone( '' );
+ // Should return a valid ISO string (from toISOString).
+ expect( result ).toMatch( /^\d{4}-\d{2}-\d{2}T/ );
+ } );
+} );
+
+describe( 'parseNaiveToMs', () => {
+ test( 'parses a naive ISO datetime string to UTC milliseconds', () => {
+ const ms = parseNaiveToMs( '2026-03-15T14:30:00' );
+ expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) );
+ } );
+
+ test( 'handles space separator between date and time', () => {
+ const ms = parseNaiveToMs( '2026-03-15 14:30:00' );
+ expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) );
+ } );
+
+ test( 'handles datetime without seconds', () => {
+ const ms = parseNaiveToMs( '2026-03-15T14:30' );
+ expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) );
+ } );
+
+ test( 'falls back to Date constructor for unparseable strings', () => {
+ const ms = parseNaiveToMs( 'not-a-date' );
+ expect( ms ).toBeNaN();
+ } );
+} );
+
+describe( 'isFutureDate', () => {
+ test( 'returns true for a date far in the future', () => {
+ expect( isFutureDate( '2099-12-31T23:59:00', 'UTC' ) ).toBe( true );
+ } );
+
+ test( 'returns false for a date in the past', () => {
+ expect( isFutureDate( '2000-01-01T00:00:00', 'UTC' ) ).toBe( false );
+ } );
+
+ test( 'returns false for the current time (within 1-minute buffer)', () => {
+ // Get the current time in UTC and check it's not considered future.
+ const now = getCurrentDateInTimezone( 'UTC' );
+ expect( isFutureDate( now, 'UTC' ) ).toBe( false );
+ } );
+
+ test( 'uses 1-minute buffer -- date exactly 1 minute ahead is not future', () => {
+ // Build a date exactly 60 seconds from "now" in UTC.
+ const nowMs = Date.now();
+ const justAhead = new Date( nowMs + 60000 );
+ const formatted = `${ justAhead.getUTCFullYear() }-${ String( justAhead.getUTCMonth() + 1 ).padStart( 2, '0' ) }-${ String( justAhead.getUTCDate() ).padStart( 2, '0' ) }T${ String( justAhead.getUTCHours() ).padStart( 2, '0' ) }:${ String( justAhead.getUTCMinutes() ).padStart( 2, '0' ) }:${ String( justAhead.getUTCSeconds() ).padStart( 2, '0' ) }`;
+ // Exactly at the 1-minute boundary should NOT be future (strict >).
+ expect( isFutureDate( formatted, 'UTC' ) ).toBe( false );
+ } );
+
+ test( 'date well beyond 1 minute in the future returns true', () => {
+ const nowMs = Date.now();
+ const fiveMinAhead = new Date( nowMs + 5 * 60000 );
+ const formatted = `${ fiveMinAhead.getUTCFullYear() }-${ String( fiveMinAhead.getUTCMonth() + 1 ).padStart( 2, '0' ) }-${ String( fiveMinAhead.getUTCDate() ).padStart( 2, '0' ) }T${ String( fiveMinAhead.getUTCHours() ).padStart( 2, '0' ) }:${ String( fiveMinAhead.getUTCMinutes() ).padStart( 2, '0' ) }:${ String( fiveMinAhead.getUTCSeconds() ).padStart( 2, '0' ) }`;
+ expect( isFutureDate( formatted, 'UTC' ) ).toBe( true );
+ } );
+} );
diff --git a/tests/components/save-handler-scheduling.test.js b/tests/components/save-handler-scheduling.test.js
new file mode 100644
index 0000000..2578d94
--- /dev/null
+++ b/tests/components/save-handler-scheduling.test.js
@@ -0,0 +1,120 @@
+/**
+ * Save Handler Scheduling Tests
+ *
+ * Behavioral tests for the formatScheduleDate utility function in PressThisEditor.
+ * Verifies date formatting, timezone label appending, and error handling.
+ *
+ * @package press-this
+ */
+
+import { formatScheduleDate } from '../../src/components/PressThisEditor';
+
+// Mock all @wordpress/* dependencies so the module loads without React/DOM.
+jest.mock( '@wordpress/element', () => ( {
+ useMemo: ( fn ) => fn(),
+ useCallback: ( fn ) => fn,
+ useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ],
+ useEffect: jest.fn(),
+ useRef: ( val ) => ( { current: val } ),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ useSelect: jest.fn( () => false ),
+ useDispatch: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '@wordpress/blocks', () => ( {
+ parse: jest.fn( () => [] ),
+ registerCoreBlocks: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-library', () => ( {
+ registerCoreBlocks: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => ( {
+ BlockEditorProvider: 'BlockEditorProvider',
+ BlockList: 'BlockList',
+ BlockTools: 'BlockTools',
+ WritingFlow: 'WritingFlow',
+ ObserveTyping: 'ObserveTyping',
+ BlockEditorKeyboardShortcuts: { Register: 'Register' },
+ BlockToolbar: 'BlockToolbar',
+ BlockInspector: 'BlockInspector',
+ Inserter: 'Inserter',
+ store: { name: 'core/block-editor' },
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ SlotFillProvider: 'SlotFillProvider',
+ Popover: Object.assign( () => null, { Slot: 'Slot' } ),
+ Button: 'Button',
+ Spinner: 'Spinner',
+ Snackbar: 'Snackbar',
+ Panel: 'Panel',
+ PanelBody: 'PanelBody',
+ FormTokenField: 'FormTokenField',
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: ( text ) => text,
+ sprintf: ( fmt, ...args ) => {
+ let result = fmt;
+ args.forEach( ( arg ) => {
+ result = result.replace( '%s', arg );
+ } );
+ return result;
+ },
+} ) );
+
+// Mock internal component imports to prevent cascading dependency issues.
+jest.mock( '../../src/components/BlockTransformShortcuts', () => 'BlockTransformShortcuts' );
+jest.mock( '../../src/components/ScrapedMediaPanel', () => 'ScrapedMediaPanel' );
+jest.mock( '../../src/components/FeaturedImagePanel', () => 'FeaturedImagePanel' );
+jest.mock( '../../src/components/CategoryPanel', () => 'CategoryPanel' );
+
+describe( 'formatScheduleDate', () => {
+ test( 'formats a naive ISO datetime into a human-readable string', () => {
+ const result = formatScheduleDate( '2026-06-15T14:30:00', '' );
+ // Should contain the date components (month, day, year, time).
+ expect( result ).toMatch( /June/ );
+ expect( result ).toMatch( /15/ );
+ expect( result ).toMatch( /2026/ );
+ expect( result ).toMatch( /2:30/ );
+ } );
+
+ test( 'appends timezone identifier when provided', () => {
+ const result = formatScheduleDate( '2026-06-15T14:30:00', 'America/New_York' );
+ expect( result ).toContain( 'America/New_York' );
+ } );
+
+ test( 'appends fixed-offset timezone string', () => {
+ const result = formatScheduleDate( '2026-06-15T14:30:00', 'UTC+2' );
+ expect( result ).toContain( 'UTC+2' );
+ } );
+
+ test( 'does not append timezone when empty', () => {
+ const result = formatScheduleDate( '2026-06-15T14:30:00', '' );
+ // Should not have trailing whitespace from empty timezone.
+ expect( result ).toBe( result.trim() );
+ } );
+
+ test( 'returns the raw string for unparseable input', () => {
+ const result = formatScheduleDate( 'not-a-date', 'UTC' );
+ expect( result ).toBe( 'not-a-date' );
+ } );
+
+ test( 'handles space separator in datetime', () => {
+ const result = formatScheduleDate( '2026-06-15 14:30:00', '' );
+ expect( result ).toMatch( /June/ );
+ expect( result ).toMatch( /15/ );
+ } );
+
+ test( 'formats correctly for different months', () => {
+ const jan = formatScheduleDate( '2026-01-05T09:00:00', '' );
+ expect( jan ).toMatch( /January/ );
+
+ const dec = formatScheduleDate( '2026-12-25T18:00:00', '' );
+ expect( dec ).toMatch( /December/ );
+ } );
+} );
diff --git a/tests/components/scheduling-integration.test.js b/tests/components/scheduling-integration.test.js
new file mode 100644
index 0000000..2336b90
--- /dev/null
+++ b/tests/components/scheduling-integration.test.js
@@ -0,0 +1,156 @@
+/**
+ * Scheduling Integration Tests
+ *
+ * Cross-cutting integration tests for the post-scheduling feature.
+ * Tests the interaction between Header scheduling utilities and
+ * PressThisEditor formatting, verifying the full data flow.
+ *
+ * @package press-this
+ */
+
+import {
+ getTimezoneAbbreviation,
+ getCurrentDateInTimezone,
+ parseNaiveToMs,
+ isFutureDate,
+} from '../../src/components/Header';
+
+import { formatScheduleDate } from '../../src/components/PressThisEditor';
+
+// Mock @wordpress dependencies for Header.
+jest.mock( '@wordpress/element', () => ( {
+ useState: ( init ) => [ typeof init === 'function' ? init() : init, jest.fn() ],
+ useCallback: ( fn ) => fn,
+ useEffect: jest.fn(),
+ useRef: ( val ) => ( { current: val } ),
+ useMemo: ( fn ) => fn(),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ Button: 'Button',
+ TextControl: 'TextControl',
+ Notice: 'Notice',
+ Tooltip: 'Tooltip',
+ DropdownMenu: 'DropdownMenu',
+ MenuGroup: 'MenuGroup',
+ MenuItem: 'MenuItem',
+ Popover: Object.assign( () => null, { Slot: 'Slot' } ),
+ DateTimePicker: 'DateTimePicker',
+ SlotFillProvider: 'SlotFillProvider',
+ Spinner: 'Spinner',
+ Snackbar: 'Snackbar',
+ Panel: 'Panel',
+ PanelBody: 'PanelBody',
+ FormTokenField: 'FormTokenField',
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: ( text ) => text,
+ sprintf: ( fmt, ...args ) => {
+ let result = fmt;
+ args.forEach( ( arg ) => {
+ result = result.replace( '%s', arg );
+ } );
+ return result;
+ },
+} ) );
+
+jest.mock( '@wordpress/icons', () => ( {
+ undo: 'undo-icon',
+ redo: 'redo-icon',
+ moreVertical: 'more-vertical-icon',
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ useSelect: jest.fn( () => false ),
+ useDispatch: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '@wordpress/blocks', () => ( {
+ parse: jest.fn( () => [] ),
+ registerCoreBlocks: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-library', () => ( {
+ registerCoreBlocks: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => ( {
+ BlockEditorProvider: 'BlockEditorProvider',
+ BlockList: 'BlockList',
+ BlockTools: 'BlockTools',
+ WritingFlow: 'WritingFlow',
+ ObserveTyping: 'ObserveTyping',
+ BlockEditorKeyboardShortcuts: { Register: 'Register' },
+ BlockToolbar: 'BlockToolbar',
+ BlockInspector: 'BlockInspector',
+ Inserter: 'Inserter',
+ store: { name: 'core/block-editor' },
+} ) );
+
+jest.mock( '../../src/components/BlockTransformShortcuts', () => 'BlockTransformShortcuts' );
+jest.mock( '../../src/components/ScrapedMediaPanel', () => 'ScrapedMediaPanel' );
+jest.mock( '../../src/components/FeaturedImagePanel', () => 'FeaturedImagePanel' );
+jest.mock( '../../src/components/CategoryPanel', () => 'CategoryPanel' );
+
+describe( 'Scheduling Integration: Header utilities + Editor formatting', () => {
+ test( 'a date produced by getCurrentDateInTimezone is parseable by parseNaiveToMs', () => {
+ const dateStr = getCurrentDateInTimezone( 'America/Chicago' );
+ const ms = parseNaiveToMs( dateStr );
+ expect( ms ).not.toBeNaN();
+ expect( ms ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'a date from getCurrentDateInTimezone is formattable by formatScheduleDate', () => {
+ const dateStr = getCurrentDateInTimezone( 'Europe/London' );
+ const formatted = formatScheduleDate( dateStr, 'Europe/London' );
+ expect( formatted ).toContain( 'Europe/London' );
+ // Should contain year.
+ expect( formatted ).toMatch( /\d{4}/ );
+ } );
+
+ test( 'isFutureDate and formatScheduleDate agree on a far-future date', () => {
+ const futureDate = '2099-06-15T14:30:00';
+ expect( isFutureDate( futureDate, 'UTC' ) ).toBe( true );
+ const formatted = formatScheduleDate( futureDate, 'UTC' );
+ expect( formatted ).toMatch( /June/ );
+ expect( formatted ).toMatch( /2099/ );
+ } );
+
+ test( 'timezone abbreviation matches the timezone appended to formatted date', () => {
+ const date = '2026-01-15T10:00:00';
+ const tz = 'America/New_York';
+ const abbr = getTimezoneAbbreviation( tz, date );
+ const formatted = formatScheduleDate( date, tz );
+ // formatScheduleDate appends the IANA string, not the abbreviation.
+ expect( formatted ).toContain( tz );
+ // But the abbreviation is a real value (not empty).
+ expect( abbr.length ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'round-trip: parse -> check future -> format for a scheduled post', () => {
+ const scheduledDate = '2099-03-15T09:00:00';
+ const tz = 'Asia/Tokyo';
+
+ // Step 1: Parse the date.
+ const ms = parseNaiveToMs( scheduledDate );
+ expect( ms ).not.toBeNaN();
+
+ // Step 2: Check if it's in the future.
+ expect( isFutureDate( scheduledDate, tz ) ).toBe( true );
+
+ // Step 3: Format for the snackbar.
+ const formatted = formatScheduleDate( scheduledDate, tz );
+ expect( formatted ).toMatch( /March/ );
+ expect( formatted ).toMatch( /2099/ );
+ expect( formatted ).toContain( 'Asia/Tokyo' );
+ } );
+
+ test( 'past date is not future and formats correctly', () => {
+ const pastDate = '2020-06-01T08:00:00';
+ expect( isFutureDate( pastDate, 'UTC' ) ).toBe( false );
+ const formatted = formatScheduleDate( pastDate, 'UTC' );
+ expect( formatted ).toMatch( /June/ );
+ expect( formatted ).toMatch( /2020/ );
+ } );
+} );
diff --git a/tests/php/test-scheduling.php b/tests/php/test-scheduling.php
new file mode 100644
index 0000000..e1e02e2
--- /dev/null
+++ b/tests/php/test-scheduling.php
@@ -0,0 +1,267 @@
+editor_user_id = wp_insert_user(
+ array(
+ 'user_login' => 'sched_editor_' . wp_generate_password( 6, false ),
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'sched_editor_' . wp_generate_password( 6, false ) . '@example.com',
+ 'role' => 'editor',
+ )
+ );
+
+ // Create a contributor user (lacks publish_posts).
+ $this->contributor_user_id = wp_insert_user(
+ array(
+ 'user_login' => 'sched_contrib_' . wp_generate_password( 6, false ),
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'sched_contrib_' . wp_generate_password( 6, false ) . '@example.com',
+ 'role' => 'contributor',
+ )
+ );
+
+ // Create a draft post owned by the editor.
+ wp_set_current_user( $this->editor_user_id );
+ $this->test_post_id = wp_insert_post(
+ array(
+ 'post_author' => $this->editor_user_id,
+ 'post_status' => 'draft',
+ 'post_title' => 'Scheduling Test Post',
+ 'post_content' => '',
+ )
+ );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down() {
+ remove_filter( 'pre_http_request', array( $this, 'block_http' ), 1 );
+ parent::tear_down();
+ }
+
+ /**
+ * Block all HTTP requests during tests.
+ *
+ * @return WP_Error
+ */
+ public function block_http() {
+ return new WP_Error( 'http_blocked', 'Blocked in test' );
+ }
+
+ /**
+ * Build a WP_REST_Request for the save endpoint.
+ *
+ * @param array $params Parameters to set on the request.
+ * @return WP_REST_Request
+ */
+ protected function build_save_request( $params = array() ) {
+ $request = new WP_REST_Request( 'POST', '/press-this/v1/save' );
+ $request->set_param( 'post_id', $this->test_post_id );
+ $request->set_param( 'title', 'Scheduled Post' );
+ $request->set_param( 'content', 'Content
' );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ return $request;
+ }
+
+ /**
+ * Test that 'future' is accepted in the status enum on the save endpoint.
+ */
+ public function test_future_status_is_accepted() {
+ wp_set_current_user( $this->editor_user_id );
+
+ $future_date = gmdate( 'Y-m-d\TH:i:s', strtotime( '+1 week' ) );
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $future_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $data = $response->get_data();
+ $this->assertTrue( $data['success'] );
+ $this->assertEquals( 'future', get_post_status( $this->test_post_id ) );
+ }
+
+ /**
+ * Test that a date parameter is accepted and validated with strtotime().
+ */
+ public function test_date_parameter_is_validated() {
+ wp_set_current_user( $this->editor_user_id );
+
+ $future_date = '2027-06-15T14:30:00';
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $future_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+
+ $post = get_post( $this->test_post_id );
+ $this->assertEquals( '2027-06-15 14:30:00', $post->post_date );
+ }
+
+ /**
+ * Test that post_date and post_date_gmt are set when status is 'future' with a valid date.
+ */
+ public function test_post_date_and_gmt_set_for_future_status() {
+ wp_set_current_user( $this->editor_user_id );
+
+ $future_date = '2027-06-15T14:30:00';
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $future_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+
+ $post = get_post( $this->test_post_id );
+ $this->assertEquals( '2027-06-15 14:30:00', $post->post_date );
+ $this->assertNotEmpty( $post->post_date_gmt );
+ $this->assertNotEquals( '0000-00-00 00:00:00', $post->post_date_gmt );
+ }
+
+ /**
+ * Test that 'future' status without a valid date returns a WP_Error.
+ */
+ public function test_future_status_without_date_returns_error() {
+ wp_set_current_user( $this->editor_user_id );
+
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => '',
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+
+ $this->assertInstanceOf( WP_Error::class, $response );
+ $this->assertEquals( 'press_this_invalid_date', $response->get_error_code() );
+ }
+
+ /**
+ * Test that rescheduling an already-scheduled post updates the date.
+ */
+ public function test_reschedule_updates_date() {
+ wp_set_current_user( $this->editor_user_id );
+
+ // First, schedule the post for an initial date.
+ $initial_date = '2027-06-15T14:30:00';
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $initial_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $this->assertEquals( 'future', get_post_status( $this->test_post_id ) );
+ $this->assertEquals( '2027-06-15 14:30:00', get_post( $this->test_post_id )->post_date );
+
+ // Reschedule to a different date.
+ $new_date = '2027-09-20T10:00:00';
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $new_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+
+ $post = get_post( $this->test_post_id );
+ $this->assertEquals( 'future', $post->post_status );
+ $this->assertEquals( '2027-09-20 10:00:00', $post->post_date );
+ $this->assertNotEquals( '2027-06-15 14:30:00', $post->post_date );
+ }
+
+ /**
+ * Test that 'future' status is gated behind publish_posts capability.
+ */
+ public function test_future_status_gated_behind_publish_posts() {
+ wp_set_current_user( $this->contributor_user_id );
+
+ // Grant the contributor edit access to the post for the test.
+ wp_update_post(
+ array(
+ 'ID' => $this->test_post_id,
+ 'post_author' => $this->contributor_user_id,
+ )
+ );
+
+ $future_date = gmdate( 'Y-m-d\TH:i:s', strtotime( '+1 week' ) );
+ $request = $this->build_save_request(
+ array(
+ 'status' => 'future',
+ 'date' => $future_date,
+ )
+ );
+
+ $response = press_this_rest_save_post( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $this->assertEquals( 'pending', get_post_status( $this->test_post_id ) );
+ }
+}