From c31a39374e9eac25de56c4b8bdeac94b038e5c7f Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 6 Mar 2026 23:36:59 +0100 Subject: [PATCH 1/7] Add post scheduling feature Allow users to schedule posts for future publication from the More actions dropdown menu, using a DateTimePicker popover with timezone display and dynamic Schedule/Publish confirmation button. Closes #31 --- class-wp-press-this-plugin.php | 3 + includes/class-press-this-assets.php | 1 + press-this-plugin.php | 44 +++- src/App.js | 25 ++ src/components/Header.js | 231 +++++++++++++++-- src/components/PressThisEditor.js | 89 +++++-- src/styles/main.scss | 3 + src/styles/partials/_header-schedule.scss | 64 +++++ tests/components/header-schedule-ui.test.js | 88 +++++++ .../save-handler-scheduling.test.js | 111 ++++++++ .../components/scheduling-integration.test.js | 243 ++++++++++++++++++ tests/php/test-scheduling.php | 229 +++++++++++++++++ 12 files changed, 1094 insertions(+), 37 deletions(-) create mode 100644 src/styles/partials/_header-schedule.scss create mode 100644 tests/components/header-schedule-ui.test.js create mode 100644 tests/components/save-handler-scheduling.test.js create mode 100644 tests/components/scheduling-integration.test.js create mode 100644 tests/php/test-scheduling.php diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php index 80b5ac4..c65a1b6 100644 --- a/class-wp-press-this-plugin.php +++ b/class-wp-press-this-plugin.php @@ -1556,6 +1556,8 @@ public function html() { 'postId' => $post_ID, 'title' => $post_title, 'content' => $post_content, + 'postStatus' => get_post_status( $post_ID ), + 'postDate' => get_post( $post_ID )->post_date, 'nonce' => wp_create_nonce( 'update-post_' . $post_ID ), 'categoryNonce' => wp_create_nonce( 'add-category' ), @@ -1619,6 +1621,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..3b13263 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}/', $value ) ) { + return new WP_Error( + '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,30 @@ 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'; + $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 32ba56f..ec148c0 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( () => ( { @@ -310,6 +314,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 @@ -354,6 +373,10 @@ export default function App() { onRedo={ redoHandler } hasUndo={ hasUndo } hasRedo={ hasRedo } + capabilities={ capabilities } + timezone={ data.timezone } + postStatus={ postStatus } + postDate={ postDate } />
@@ -371,6 +394,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..cae99d2 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,85 @@ function isMacOS() { ); } +/** + * Derive a short timezone abbreviation from a timezone string. + * + * @param {string} tz Timezone string (e.g., 'America/New_York' or 'UTC+5'). + * @return {string} Timezone abbreviation (e.g., 'EST' or 'UTC+5'). + */ +function getTimezoneAbbreviation( tz ) { + 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', + } ); + const parts = formatter.formatToParts( new Date() ); + 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 ) { + if ( ! tz ) { + return new Date().toISOString(); + } + + 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 new Date().toISOString(); + } +} + +/** + * 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 compared as naive wall-clock times + * in the site timezone, so the browser's local timezone does not affect the result. + * + * @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 = new Date( dateString ).getTime(); + const nowInSiteTz = getCurrentDateInTimezone( tz ); + const nowMs = new Date( nowInSiteTz ).getTime(); + return selectedMs - nowMs > ONE_MINUTE; +} + /** * Header component. * @@ -66,6 +149,10 @@ 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 default function Header( { @@ -86,6 +173,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 +184,36 @@ 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 ); + 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 +391,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 +519,85 @@ export default function Header( { > { publishLabel } - - { ( { onClose } ) => ( - - { - handleContinueInEditor(); - onClose(); - } } - > - { __( - 'Continue in Standard Editor', - 'press-this' +
+ + { ( { onClose } ) => ( + <> + + { + handleContinueInEditor(); + onClose(); + } } + > + { __( + 'Continue in Standard Editor', + 'press-this' + ) } + + + { capabilities.canPublish && ( + + { + setIsScheduleOpen( true ); + setScheduleDate( + postStatus === 'future' && postDate + ? postDate + : getCurrentDateInTimezone( timezone ) + ); + onClose(); + } } + > + { scheduleMenuLabel } + + ) } - - - ) } - + + ) } + +
+ { isScheduleOpen && ( + + setIsScheduleOpen( false ) + } + placement="bottom-end" + className="press-this-header__schedule-popover" + aria-label={ __( 'Schedule post', 'press-this' ) } + > +
+ + { timezoneAbbreviation && ( +

+ { timezoneAbbreviation } +

+ ) } + +
+
+ ) }
) } diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index 143a3fc..054a9ea 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,23 +228,55 @@ 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 IANA timezone string (e.g., "America/New_York"). + * @return {string} Human-readable formatted date. + */ +function formatScheduleDate( dateString, timezone ) { + try { + const date = new Date( dateString ); + const options = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }; + if ( timezone ) { + options.timeZone = timezone; + } + return new Intl.DateTimeFormat( undefined, options ).format( date ); + } 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. @@ -263,6 +295,8 @@ export default function PressThisEditor( { onScrapeProcessed = () => {}, onSaveReady = () => {}, onUndoReady = () => {}, + timezone = '', + onPostStatusChange = () => {}, categoryNonce = '', ajaxUrl = '', } ) { @@ -472,7 +506,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 +533,7 @@ export default function PressThisEditor( { tags, featured_image: featuredImageId, force_redirect: options.forceRedirect || false, + date: options.date || '', } ), } ); @@ -517,6 +552,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 +605,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..fc06453 --- /dev/null +++ b/tests/components/header-schedule-ui.test.js @@ -0,0 +1,88 @@ +/** + * Header Schedule UI Tests + * + * Tests for the Schedule MenuItem, DateTimePicker Popover, past/future date logic, + * confirmation handler, and popover dismissibility in the Header component. + * + * @package press-this + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +describe( 'Header Schedule UI', () => { + let headerContent; + + beforeAll( () => { + const headerPath = path.resolve( + __dirname, + '../../src/components/Header.js' + ); + headerContent = fs.readFileSync( headerPath, 'utf8' ); + } ); + + test( 'a "Schedule" MenuItem exists in the More actions dropdown', () => { + // There should be a MenuItem with the label "Schedule" (or "Reschedule"). + expect( headerContent ).toContain( 'MenuItem' ); + + // The Schedule menu item should exist inside a MenuGroup. + expect( headerContent ).toMatch( /MenuGroup[\s\S]*?Schedule/ ); + } ); + + test( 'Schedule MenuItem is only rendered when canPublish is truthy', () => { + // The capabilities prop should be destructured. + expect( headerContent ).toContain( 'capabilities' ); + + // canPublish should gate the Schedule menu item. + expect( headerContent ).toMatch( /capabilities\.canPublish/ ); + } ); + + test( 'clicking Schedule opens a Popover with a DateTimePicker', () => { + // Check for Popover import/usage. + expect( headerContent ).toContain( 'Popover' ); + + // Check for DateTimePicker import/usage. + expect( headerContent ).toContain( 'DateTimePicker' ); + + // Check for isScheduleOpen state to control the popover. + expect( headerContent ).toContain( 'isScheduleOpen' ); + + // Check that the schedule menu item sets the popover open. + expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*true\s*\)/ ); + } ); + + test( 'confirmation button reads "Schedule" for future dates and "Publish" for past dates', () => { + // Check for the 1-minute buffer pattern (matching Gutenberg's isEditedPostBeingScheduled). + expect( headerContent ).toMatch( /ONE_MINUTE|60\s*\*\s*1000/ ); + + // The button label should dynamically switch between Schedule and Publish. + // Look for logic that compares scheduleDate against now. + expect( headerContent ).toMatch( /Schedule/ ); + + // The label should use __() for i18n. + expect( headerContent ).toMatch( + /__\(\s*'Schedule'\s*,\s*'press-this'\s*\)/ + ); + } ); + + test( 'confirming calls onSave with correct status and date for future/past dates', () => { + // For future dates: onSave( 'future', { date } ). + expect( headerContent ).toMatch( /onSave\(\s*'future'/ ); + + // The future save should include a date in the options. + expect( headerContent ).toMatch( + /onSave\(\s*'future'\s*,\s*\{[\s\S]*?date/ + ); + + // For past dates: onSave( 'publish' ). + expect( headerContent ).toMatch( /onSave\(\s*'publish'\s*\)/ ); + } ); + + test( 'popover is dismissible (closes on outside click or Escape)', () => { + // The Popover component should have an onClose handler that sets isScheduleOpen to false. + expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*false\s*\)/ ); + + // Verify onClose is passed to Popover (handles both outside click and Escape). + expect( headerContent ).toMatch( /Popover[\s\S]*?onClose/ ); + } ); +} ); diff --git a/tests/components/save-handler-scheduling.test.js b/tests/components/save-handler-scheduling.test.js new file mode 100644 index 0000000..e8ea2fe --- /dev/null +++ b/tests/components/save-handler-scheduling.test.js @@ -0,0 +1,111 @@ +/** + * Save Handler and Data Threading Tests + * + * Tests for scheduling support in the save handler and timezone data threading. + * Verifies that handleSave passes date in the REST body, the schedule-specific + * snackbar displays correctly, timezone flows through the component tree, and + * no redirect occurs for 'future' status. + * + * @package press-this + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +describe( 'Save Handler: scheduling support', () => { + let editorContent; + + beforeAll( () => { + const editorPath = path.resolve( + __dirname, + '../../src/components/PressThisEditor.js' + ); + editorContent = fs.readFileSync( editorPath, 'utf8' ); + } ); + + test( 'handleSave includes date in the REST request body when options.date is provided', () => { + // The JSON.stringify body should include a date field sourced from options.date. + expect( editorContent ).toMatch( /date:\s*options\.date/ ); + + // Verify it is inside the JSON.stringify call alongside other body fields. + const bodyMatch = editorContent.match( + /body:\s*JSON\.stringify\(\s*\{([\s\S]*?)\}\s*\)/ + ); + expect( bodyMatch ).not.toBeNull(); + expect( bodyMatch[ 1 ] ).toContain( 'date' ); + } ); + + test( 'successful schedule response shows a snackbar with the formatted date', () => { + // Check for 'future' status detection in the success handler. + expect( editorContent ).toMatch( /status\s*===\s*'future'/ ); + + // Check for Intl.DateTimeFormat usage for date formatting. + expect( editorContent ).toContain( 'Intl.DateTimeFormat' ); + + // Check for the schedule-specific snackbar message using sprintf. + expect( editorContent ).toMatch( /Post scheduled for %s/ ); + expect( editorContent ).toMatch( + /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ + ); + } ); + + test( 'no redirect occurs when status is future (response has no redirect key)', () => { + // The existing success handler shows a notice when result.redirect is falsy. + // For 'future' status, the server returns no redirect, so the no-redirect + // branch executes. Verify the no-redirect branch sets a notice (not a redirect). + expect( editorContent ).toMatch( + /if\s*\(\s*result\.redirect\s*\)/ + ); + + // The else branch (no redirect) sets a notice -- this covers 'future' status. + expect( editorContent ).toMatch( + /setNotice\(\s*\{[\s\S]*?status:\s*'success'/ + ); + } ); +} ); + +describe( 'Data Threading: timezone from App to components', () => { + let appContent; + let editorContent; + let headerContent; + + beforeAll( () => { + const appPath = path.resolve( __dirname, '../../src/App.js' ); + const editorPath = path.resolve( + __dirname, + '../../src/components/PressThisEditor.js' + ); + const headerPath = path.resolve( + __dirname, + '../../src/components/Header.js' + ); + appContent = fs.readFileSync( appPath, 'utf8' ); + editorContent = fs.readFileSync( editorPath, 'utf8' ); + headerContent = fs.readFileSync( headerPath, 'utf8' ); + } ); + + test( 'App.js reads timezone from pressThisData and threads it to PressThisEditor and Header', () => { + // App.js reads data.timezone. + expect( appContent ).toContain( 'data.timezone' ); + + // App.js passes timezone prop to PressThisEditor. + expect( appContent ).toMatch( / { + let headerContent; + let editorContent; + let appContent; + + beforeAll( () => { + headerContent = fs.readFileSync( + path.resolve( __dirname, '../../src/components/Header.js' ), + 'utf8' + ); + editorContent = fs.readFileSync( + path.resolve( + __dirname, + '../../src/components/PressThisEditor.js' + ), + 'utf8' + ); + appContent = fs.readFileSync( + path.resolve( __dirname, '../../src/App.js' ), + 'utf8' + ); + } ); + + test( 'full scheduling workflow: menu item -> popover -> date selection -> confirm -> REST request -> snackbar', () => { + // 1. Schedule MenuItem exists and opens the popover. + expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*true\s*\)/ ); + + // 2. DateTimePicker is rendered in the popover with onChange wired to setScheduleDate. + expect( headerContent ).toMatch( + /DateTimePicker[\s\S]*?onChange=\{\s*setScheduleDate\s*\}/ + ); + + // 3. Confirmation button calls handleScheduleConfirm. + expect( headerContent ).toMatch( + /onClick=\{[\s\S]*?handleScheduleConfirm/ + ); + + // 4. handleScheduleConfirm calls onSave('future', { date: scheduleDate }). + expect( headerContent ).toMatch( + /onSave\(\s*'future'\s*,\s*\{\s*date:\s*scheduleDate\s*\}\s*\)/ + ); + + // 5. App.js wires onSave to saveState.handleSave from PressThisEditor. + expect( appContent ).toMatch( /onSave=\{\s*saveState\.handleSave\s*\}/ ); + + // 6. PressThisEditor's handleSave includes date in the REST body. + expect( editorContent ).toMatch( /date:\s*options\.date/ ); + + // 7. On success with no redirect and 'future' status, snackbar is shown. + expect( editorContent ).toMatch( + /status\s*===\s*'future'\s*&&\s*options\.date/ + ); + expect( editorContent ).toMatch( /Post scheduled for %s/ ); + } ); + + test( 'post saved with future status does not trigger a redirect', () => { + // The save handler checks result.redirect first, and the future status + // branch is in an 'else if' -- meaning it only runs when there is NO redirect. + // This ensures scheduled posts stay in Press This instead of redirecting. + + // Verify the 'else if' chain: redirect check followed by future status check. + expect( editorContent ).toMatch( + /if\s*\(\s*result\.redirect\s*\)\s*\{[\s\S]*?\}\s*else\s+if\s*\(\s*status\s*===\s*'future'/ + ); + + // The future branch shows a snackbar instead of redirecting. + expect( editorContent ).toMatch( + /status\s*===\s*'future'[\s\S]*?setNotice\(\s*\{[\s\S]*?status:\s*'success'/ + ); + + // Verify that performSafeRedirect is NOT called in the future branch -- + // it only appears in the redirect branch. + const futureBlock = editorContent.match( + /else\s+if\s*\(\s*status\s*===\s*'future'\s*&&\s*options\.date\s*\)\s*\{([\s\S]*?)\}\s*else\s*\{/ + ); + expect( futureBlock ).not.toBeNull(); + expect( futureBlock[ 1 ] ).not.toContain( 'performSafeRedirect' ); + } ); + + test( 'Schedule menu item is hidden when canPublish is false', () => { + // The Schedule MenuGroup is wrapped in a conditional on capabilities.canPublish. + // Verify the conditional rendering pattern wraps the MenuGroup containing Schedule. + expect( headerContent ).toMatch( + /capabilities\.canPublish\s*&&\s*\(\s*\n?\s*/ + ); + + // Verify the Schedule/Reschedule label is inside that conditional block. + const conditionalMatch = headerContent.match( + /capabilities\.canPublish\s*&&\s*\(([\s\S]*?)<\/MenuGroup>/ + ); + expect( conditionalMatch ).not.toBeNull(); + expect( conditionalMatch[ 1 ] ).toContain( 'scheduleMenuLabel' ); + } ); + + test( 'snackbar shows correctly formatted date with timezone', () => { + // formatScheduleDate function exists and uses Intl.DateTimeFormat. + expect( editorContent ).toMatch( + /function\s+formatScheduleDate\s*\(\s*dateString\s*,\s*timezone\s*\)/ + ); + + // It passes the timezone to Intl.DateTimeFormat options. + expect( editorContent ).toMatch( + /options\.timeZone\s*=\s*timezone/ + ); + + // It uses timeZoneName: 'short' to include timezone abbreviation. + expect( editorContent ).toMatch( + /timeZoneName:\s*'short'/ + ); + + // The formatted result is used in the snackbar message via sprintf. + expect( editorContent ).toMatch( + /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ + ); + } ); + + test( 'date exactly at the 1-minute buffer threshold is treated as not-future', () => { + // The isFutureDate function uses a strict greater-than comparison with ONE_MINUTE. + // It now accepts a tz parameter to compare in the site timezone. + const isFutureFn = headerContent.match( + /function\s+isFutureDate\s*\(\s*dateString\s*,\s*tz\s*\)\s*\{([\s\S]*?)\n\}/ + ); + expect( isFutureFn ).not.toBeNull(); + + const fnBody = isFutureFn[ 1 ]; + + // Uses getCurrentDateInTimezone for current time reference instead of Date.now(). + expect( fnBody ).toContain( 'getCurrentDateInTimezone' ); + + // Computes difference as selectedMs - nowMs. + expect( fnBody ).toMatch( /selectedMs\s*-\s*nowMs/ ); + + // Uses strict greater-than with ONE_MINUTE (not >=). + // This means a date exactly ONE_MINUTE from now returns false (not future). + expect( fnBody ).toMatch( />\s*ONE_MINUTE/ ); + expect( fnBody ).not.toMatch( />=\s*ONE_MINUTE/ ); + } ); + + test( 'popover re-opens cleanly after a previous schedule action', () => { + // After handleScheduleConfirm, the popover is closed. + expect( headerContent ).toMatch( + /handleScheduleConfirm[\s\S]*?setIsScheduleOpen\(\s*false\s*\)/ + ); + + // When the Schedule menu item is clicked again, the date resets + // to either the post's scheduled date or the current time. + const menuItemClick = headerContent.match( + /setIsScheduleOpen\(\s*true\s*\)[\s\S]*?setScheduleDate\(/ + ); + expect( menuItemClick ).not.toBeNull(); + + // The date is freshly computed via getCurrentDateInTimezone on re-open + // (not stale from the previous session). + expect( headerContent ).toMatch( + /setScheduleDate\(\s*\n?\s*postStatus\s*===\s*'future'\s*&&\s*postDate[\s\S]*?getCurrentDateInTimezone/ + ); + } ); + + test( 'DateTimePicker defaults to current date/time on first open', () => { + // The scheduleDate state initializer calls getCurrentDateInTimezone + // when there is no existing scheduled post. + expect( headerContent ).toMatch( + /useState\(\s*\(\)\s*=>\s*\{[\s\S]*?getCurrentDateInTimezone\(\s*timezone\s*\)/ + ); + + // getCurrentDateInTimezone uses Intl.DateTimeFormat with the site timezone. + expect( headerContent ).toMatch( + /function\s+getCurrentDateInTimezone\s*\(\s*tz\s*\)/ + ); + + // It formats in ISO 8601 pattern (YYYY-MM-DDTHH:MM:SS). + expect( headerContent ).toMatch( + /get\(\s*'year'\s*\)[\s\S]*?get\(\s*'month'\s*\)[\s\S]*?get\(\s*'day'\s*\)/ + ); + + // DateTimePicker receives scheduleDate as currentDate. + expect( headerContent ).toMatch( + /DateTimePicker[\s\S]*?currentDate=\{\s*scheduleDate\s*\}/ + ); + } ); + + test( 'post status updates in App.js after scheduling', () => { + // App.js maintains postStatus and postDate as React state. + expect( appContent ).toMatch( /\[\s*postStatus\s*,\s*setPostStatus\s*\]/ ); + expect( appContent ).toMatch( /\[\s*postDate\s*,\s*setPostDate\s*\]/ ); + + // App.js passes onPostStatusChange callback to PressThisEditor. + expect( appContent ).toMatch( + /onPostStatusChange=\{\s*handlePostStatusChange\s*\}/ + ); + + // App.js passes postStatus and postDate to Header from state (not static data). + expect( appContent ).toMatch( /postStatus=\{\s*postStatus\s*\}/ ); + expect( appContent ).toMatch( /postDate=\{\s*postDate\s*\}/ ); + + // PressThisEditor calls onPostStatusChange after a successful future save. + expect( editorContent ).toMatch( /onPostStatusChange\(/ ); + } ); + + test( 'schedule popover has accessible aria-label', () => { + // The Popover should have an aria-label for accessibility. + expect( headerContent ).toMatch( + /Popover[\s\S]*?aria-label=\{\s*__\(\s*'Schedule post'/ + ); + } ); + + test( 'snackbar message uses sprintf for translatable formatting', () => { + // The snackbar message should use sprintf with __() instead of a template literal. + expect( editorContent ).toContain( 'sprintf' ); + expect( editorContent ).toMatch( + /import\s*\{[^}]*sprintf[^}]*\}\s*from\s*'@wordpress\/i18n'/ + ); + expect( editorContent ).toMatch( + /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ + ); + } ); + + test( 'formatScheduleDate uses browser default locale', () => { + // formatScheduleDate should use undefined (browser default) instead of 'en-US'. + expect( editorContent ).toMatch( + /new\s+Intl\.DateTimeFormat\(\s*undefined\s*,/ + ); + // It should NOT hardcode 'en-US'. + const formatFn = editorContent.match( + /function\s+formatScheduleDate[\s\S]*?\n\}/ + ); + expect( formatFn ).not.toBeNull(); + expect( formatFn[ 0 ] ).not.toMatch( /DateTimeFormat\(\s*'en-US'/ ); + } ); +} ); diff --git a/tests/php/test-scheduling.php b/tests/php/test-scheduling.php new file mode 100644 index 0000000..a2b5931 --- /dev/null +++ b/tests/php/test-scheduling.php @@ -0,0 +1,229 @@ +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 '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 ) ); + } +} From de74a09738cd3c06191a21a585ef5e159cdedb8e Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Sat, 7 Mar 2026 00:05:31 +0100 Subject: [PATCH 2/7] Address Copilot review feedback - Use mysql_to_rfc3339() for postDate to ensure consistent cross-browser parsing - Derive timezone abbreviation from selected date instead of current date (DST accuracy) - Avoid browser-timezone reinterpretation in formatScheduleDate by formatting in UTC - Rename error code to press_this_invalid_date_format for API consistency --- class-wp-press-this-plugin.php | 2 +- press-this-plugin.php | 2 +- src/components/Header.js | 10 ++-- src/components/PressThisEditor.js | 48 +++++++++++++++++-- .../components/scheduling-integration.test.js | 20 ++++---- 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php index c65a1b6..2d079e5 100644 --- a/class-wp-press-this-plugin.php +++ b/class-wp-press-this-plugin.php @@ -1557,7 +1557,7 @@ public function html() { 'title' => $post_title, 'content' => $post_content, 'postStatus' => get_post_status( $post_ID ), - 'postDate' => get_post( $post_ID )->post_date, + 'postDate' => mysql_to_rfc3339( get_post( $post_ID )->post_date ), 'nonce' => wp_create_nonce( 'update-post_' . $post_ID ), 'categoryNonce' => wp_create_nonce( 'add-category' ), diff --git a/press-this-plugin.php b/press-this-plugin.php index 3b13263..a0cc7ba 100644 --- a/press-this-plugin.php +++ b/press-this-plugin.php @@ -195,7 +195,7 @@ function press_this_register_rest_routes() { } if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $value ) ) { return new WP_Error( - 'invalid_date_format', + 'press_this_invalid_date_format', __( 'Date must be in ISO 8601 format.', 'press-this' ), array( 'status' => 400 ) ); diff --git a/src/components/Header.js b/src/components/Header.js index cae99d2..068b89d 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -52,10 +52,11 @@ 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} 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 ) { +function getTimezoneAbbreviation( tz, date ) { if ( ! tz ) { return ''; } @@ -70,7 +71,8 @@ function getTimezoneAbbreviation( tz ) { timeZone: tz, timeZoneName: 'short', } ); - const parts = formatter.formatToParts( new Date() ); + const refDate = date ? new Date( date ) : new Date(); + const parts = formatter.formatToParts( refDate ); const tzPart = parts.find( ( p ) => p.type === 'timeZoneName' ); return tzPart ? tzPart.value : tz; } catch { @@ -203,7 +205,7 @@ export default function Header( { const undoShortcut = isMacOS() ? '\u2318Z' : 'Ctrl+Z'; const redoShortcut = isMacOS() ? '\u21E7\u2318Z' : 'Ctrl+Shift+Z'; - const timezoneAbbreviation = getTimezoneAbbreviation( timezone ); + const timezoneAbbreviation = getTimezoneAbbreviation( timezone, scheduleDate ); const isScheduleFuture = isFutureDate( scheduleDate, timezone ); const scheduleButtonLabel = isScheduleFuture ? __( 'Schedule', 'press-this' ) diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index 054a9ea..55d1f36 100644 --- a/src/components/PressThisEditor.js +++ b/src/components/PressThisEditor.js @@ -240,19 +240,59 @@ function getWpRestBaseUrl( pressThisRestUrl ) { */ function formatScheduleDate( dateString, timezone ) { try { - const date = new Date( dateString ); + // 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', - timeZoneName: 'short', + timeZone: 'UTC', }; + const formatted = new Intl.DateTimeFormat( undefined, options ).format( + utcDate + ); + // Append timezone abbreviation if available. if ( timezone ) { - options.timeZone = timezone; + try { + let abbr = timezone; + if ( ! /^UTC[+-]?\d*$/.test( timezone ) ) { + const tzFormatter = new Intl.DateTimeFormat( 'en-US', { + timeZone: timezone, + timeZoneName: 'short', + } ); + const tzParts = tzFormatter.formatToParts( utcDate ); + const tzPart = tzParts.find( + ( p ) => p.type === 'timeZoneName' + ); + if ( tzPart ) { + abbr = tzPart.value; + } + } + return `${ formatted } ${ abbr }`; + } catch { + // Fall through to return formatted without abbreviation. + } } - return new Intl.DateTimeFormat( undefined, options ).format( date ); + return formatted; } catch { return dateString; } diff --git a/tests/components/scheduling-integration.test.js b/tests/components/scheduling-integration.test.js index a13e24a..ac6ac49 100644 --- a/tests/components/scheduling-integration.test.js +++ b/tests/components/scheduling-integration.test.js @@ -111,12 +111,12 @@ describe( 'Scheduling Integration', () => { /function\s+formatScheduleDate\s*\(\s*dateString\s*,\s*timezone\s*\)/ ); - // It passes the timezone to Intl.DateTimeFormat options. + // It formats in UTC to avoid browser-timezone reinterpretation. expect( editorContent ).toMatch( - /options\.timeZone\s*=\s*timezone/ + /timeZone:\s*'UTC'/ ); - // It uses timeZoneName: 'short' to include timezone abbreviation. + // It derives the timezone abbreviation using timeZoneName: 'short'. expect( editorContent ).toMatch( /timeZoneName:\s*'short'/ ); @@ -228,16 +228,12 @@ describe( 'Scheduling Integration', () => { ); } ); - test( 'formatScheduleDate uses browser default locale', () => { - // formatScheduleDate should use undefined (browser default) instead of 'en-US'. + test( 'formatScheduleDate uses browser default locale for primary formatting', () => { + // The primary DateTimeFormat call should use undefined (browser default). expect( editorContent ).toMatch( - /new\s+Intl\.DateTimeFormat\(\s*undefined\s*,/ + /new\s+Intl\.DateTimeFormat\(\s*undefined\s*,\s*options\s*\)/ ); - // It should NOT hardcode 'en-US'. - const formatFn = editorContent.match( - /function\s+formatScheduleDate[\s\S]*?\n\}/ - ); - expect( formatFn ).not.toBeNull(); - expect( formatFn[ 0 ] ).not.toMatch( /DateTimeFormat\(\s*'en-US'/ ); + // The 'en-US' usage is only for deriving timezone abbreviation strings + // (not user-visible date formatting), which is acceptable. } ); } ); From 7b0412051564a8665350fe455355849640dc642f Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Sat, 7 Mar 2026 00:17:24 +0100 Subject: [PATCH 3/7] Address second round of Copilot feedback - Parse date parts in getTimezoneAbbreviation to avoid DST mismatch from browser-local interpretation - Use IANA timezone string directly in formatScheduleDate snackbar instead of deriving abbreviation from wrong instant - Anchor date validation regex to reject trailing timezone qualifiers (Z, -05:00, etc.) - Fix block_http filter accepted_args for PHP 8+ compatibility --- press-this-plugin.php | 2 +- src/components/Header.js | 20 ++++++++++++++++- src/components/PressThisEditor.js | 22 ++----------------- .../components/scheduling-integration.test.js | 4 ++-- tests/php/test-scheduling.php | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/press-this-plugin.php b/press-this-plugin.php index a0cc7ba..6a16e03 100644 --- a/press-this-plugin.php +++ b/press-this-plugin.php @@ -193,7 +193,7 @@ function press_this_register_rest_routes() { if ( empty( $value ) ) { return true; } - if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/', $value ) ) { + 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' ), diff --git a/src/components/Header.js b/src/components/Header.js index 068b89d..1e8a9d2 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -71,7 +71,25 @@ function getTimezoneAbbreviation( tz, date ) { timeZone: tz, timeZoneName: 'short', } ); - const refDate = date ? new Date( date ) : new Date(); + // 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; diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index 55d1f36..6207fc2 100644 --- a/src/components/PressThisEditor.js +++ b/src/components/PressThisEditor.js @@ -270,27 +270,9 @@ function formatScheduleDate( dateString, timezone ) { const formatted = new Intl.DateTimeFormat( undefined, options ).format( utcDate ); - // Append timezone abbreviation if available. + // Append timezone identifier if available. if ( timezone ) { - try { - let abbr = timezone; - if ( ! /^UTC[+-]?\d*$/.test( timezone ) ) { - const tzFormatter = new Intl.DateTimeFormat( 'en-US', { - timeZone: timezone, - timeZoneName: 'short', - } ); - const tzParts = tzFormatter.formatToParts( utcDate ); - const tzPart = tzParts.find( - ( p ) => p.type === 'timeZoneName' - ); - if ( tzPart ) { - abbr = tzPart.value; - } - } - return `${ formatted } ${ abbr }`; - } catch { - // Fall through to return formatted without abbreviation. - } + return `${ formatted } ${ timezone }`; } return formatted; } catch { diff --git a/tests/components/scheduling-integration.test.js b/tests/components/scheduling-integration.test.js index ac6ac49..e98ab4e 100644 --- a/tests/components/scheduling-integration.test.js +++ b/tests/components/scheduling-integration.test.js @@ -116,9 +116,9 @@ describe( 'Scheduling Integration', () => { /timeZone:\s*'UTC'/ ); - // It derives the timezone abbreviation using timeZoneName: 'short'. + // It appends the timezone identifier to the formatted date. expect( editorContent ).toMatch( - /timeZoneName:\s*'short'/ + /formatted\s*\}\s*\$\{\s*timezone\s*\}/ ); // The formatted result is used in the snackbar message via sprintf. diff --git a/tests/php/test-scheduling.php b/tests/php/test-scheduling.php index a2b5931..92d0798 100644 --- a/tests/php/test-scheduling.php +++ b/tests/php/test-scheduling.php @@ -43,7 +43,7 @@ public function set_up() { populate_roles(); // Block HTTP requests to prevent actual image downloads during save. - add_filter( 'pre_http_request', array( $this, 'block_http' ), 1 ); + add_filter( 'pre_http_request', array( $this, 'block_http' ), 1, 0 ); // Create an editor user (has publish_posts). $this->editor_user_id = wp_insert_user( From af42cccec49c2486942084cdb75996f672f39595 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Sat, 7 Mar 2026 00:20:25 +0100 Subject: [PATCH 4/7] Fix isFutureDate DST bug and address remaining review feedback - Replace new Date() parsing in isFutureDate with parseNaiveToMs() that extracts date parts directly via Date.UTC(), avoiding browser-local timezone interpretation that breaks at DST boundaries - Use IANA timezone string in formatScheduleDate snackbar (simpler, always correct) - Parse date parts in getTimezoneAbbreviation via Date.UTC() for DST accuracy - Anchor date validation regex to reject trailing tz qualifiers - Fix block_http filter accepted_args for PHP 8+ compatibility --- src/components/Header.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/Header.js b/src/components/Header.js index 1e8a9d2..1957c3d 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -130,21 +130,42 @@ function getCurrentDateInTimezone( tz ) { } } +/** + * 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 compared as naive wall-clock times - * in the site timezone, so the browser's local timezone does not affect the result. + * 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 = new Date( dateString ).getTime(); + const selectedMs = parseNaiveToMs( dateString ); const nowInSiteTz = getCurrentDateInTimezone( tz ); - const nowMs = new Date( nowInSiteTz ).getTime(); + const nowMs = parseNaiveToMs( nowInSiteTz ); return selectedMs - nowMs > ONE_MINUTE; } From 9b5fb5418164441429adcdcaae773a61807c8c78 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 13:57:41 -0500 Subject: [PATCH 5/7] Replace string-matching JS tests with behavioral tests and add reschedule coverage - Export scheduling utility functions (isFutureDate, parseNaiveToMs, getCurrentDateInTimezone, getTimezoneAbbreviation, formatScheduleDate) as named exports for direct testing - Rewrite all 3 JS test files to call functions with real inputs and assert outputs, replacing fs.readFileSync string-matching approach - Add cross-module integration tests verifying Header utilities produce data consumable by Editor formatting - Add PHP test for rescheduling an already-scheduled post to a new date - Add explanatory comment for edit_date flag in save handler --- press-this-plugin.php | 1 + src/components/Header.js | 7 + src/components/PressThisEditor.js | 2 + tests/components/header-schedule-ui.test.js | 176 ++++++--- .../save-handler-scheduling.test.js | 193 +++++----- .../components/scheduling-integration.test.js | 349 +++++++----------- tests/php/test-scheduling.php | 38 ++ 7 files changed, 401 insertions(+), 365 deletions(-) diff --git a/press-this-plugin.php b/press-this-plugin.php index 6a16e03..ac4fce8 100644 --- a/press-this-plugin.php +++ b/press-this-plugin.php @@ -378,6 +378,7 @@ function press_this_rest_save_post( $request ) { $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'; diff --git a/src/components/Header.js b/src/components/Header.js index 1957c3d..67bfd32 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -196,6 +196,13 @@ function isFutureDate( dateString, tz ) { * @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, diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index 6207fc2..ebf7239 100644 --- a/src/components/PressThisEditor.js +++ b/src/components/PressThisEditor.js @@ -303,6 +303,8 @@ function formatScheduleDate( dateString, timezone ) { * @param {string} props.ajaxUrl * @return {JSX.Element} Press This Editor component. */ +export { formatScheduleDate }; + export default function PressThisEditor( { post, settings, diff --git a/tests/components/header-schedule-ui.test.js b/tests/components/header-schedule-ui.test.js index fc06453..02881c5 100644 --- a/tests/components/header-schedule-ui.test.js +++ b/tests/components/header-schedule-ui.test.js @@ -1,88 +1,150 @@ /** * Header Schedule UI Tests * - * Tests for the Schedule MenuItem, DateTimePicker Popover, past/future date logic, - * confirmation handler, and popover dismissibility in the Header component. + * 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 */ -const fs = require( 'fs' ); -const path = require( 'path' ); - -describe( 'Header Schedule UI', () => { - let headerContent; - - beforeAll( () => { - const headerPath = path.resolve( - __dirname, - '../../src/components/Header.js' - ); - headerContent = fs.readFileSync( headerPath, 'utf8' ); +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( 'a "Schedule" MenuItem exists in the More actions dropdown', () => { - // There should be a MenuItem with the label "Schedule" (or "Reschedule"). - expect( headerContent ).toContain( 'MenuItem' ); + 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' ); + } ); - // The Schedule menu item should exist inside a MenuGroup. - expect( headerContent ).toMatch( /MenuGroup[\s\S]*?Schedule/ ); + 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( 'Schedule MenuItem is only rendered when canPublish is truthy', () => { - // The capabilities prop should be destructured. - expect( headerContent ).toContain( 'capabilities' ); + 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)/ ); + } ); - // canPublish should gate the Schedule menu item. - expect( headerContent ).toMatch( /capabilities\.canPublish/ ); + test( 'returns the timezone string on invalid IANA identifier', () => { + expect( getTimezoneAbbreviation( 'Invalid/Zone', '2026-01-15T12:00:00' ) ).toBe( 'Invalid/Zone' ); } ); +} ); - test( 'clicking Schedule opens a Popover with a DateTimePicker', () => { - // Check for Popover import/usage. - expect( headerContent ).toContain( 'Popover' ); +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}$/ ); + } ); - // Check for DateTimePicker import/usage. - expect( headerContent ).toContain( 'DateTimePicker' ); + 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}$/ ); + } ); - // Check for isScheduleOpen state to control the popover. - expect( headerContent ).toContain( 'isScheduleOpen' ); + 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/ ); + } ); +} ); - // Check that the schedule menu item sets the popover open. - expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*true\s*\)/ ); +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( 'confirmation button reads "Schedule" for future dates and "Publish" for past dates', () => { - // Check for the 1-minute buffer pattern (matching Gutenberg's isEditedPostBeingScheduled). - expect( headerContent ).toMatch( /ONE_MINUTE|60\s*\*\s*1000/ ); + 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 ) ); + } ); - // The button label should dynamically switch between Schedule and Publish. - // Look for logic that compares scheduleDate against now. - expect( headerContent ).toMatch( /Schedule/ ); + test( 'handles datetime without seconds', () => { + const ms = parseNaiveToMs( '2026-03-15T14:30' ); + expect( ms ).toBe( Date.UTC( 2026, 2, 15, 14, 30 ) ); + } ); - // The label should use __() for i18n. - expect( headerContent ).toMatch( - /__\(\s*'Schedule'\s*,\s*'press-this'\s*\)/ - ); + test( 'falls back to Date constructor for unparseable strings', () => { + const ms = parseNaiveToMs( 'not-a-date' ); + expect( ms ).toBeNaN(); } ); +} ); - test( 'confirming calls onSave with correct status and date for future/past dates', () => { - // For future dates: onSave( 'future', { date } ). - expect( headerContent ).toMatch( /onSave\(\s*'future'/ ); +describe( 'isFutureDate', () => { + test( 'returns true for a date far in the future', () => { + expect( isFutureDate( '2099-12-31T23:59:00', 'UTC' ) ).toBe( true ); + } ); - // The future save should include a date in the options. - expect( headerContent ).toMatch( - /onSave\(\s*'future'\s*,\s*\{[\s\S]*?date/ - ); + test( 'returns false for a date in the past', () => { + expect( isFutureDate( '2000-01-01T00:00:00', 'UTC' ) ).toBe( false ); + } ); - // For past dates: onSave( 'publish' ). - expect( headerContent ).toMatch( /onSave\(\s*'publish'\s*\)/ ); + 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( 'popover is dismissible (closes on outside click or Escape)', () => { - // The Popover component should have an onClose handler that sets isScheduleOpen to false. - expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*false\s*\)/ ); + 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 ); + } ); - // Verify onClose is passed to Popover (handles both outside click and Escape). - expect( headerContent ).toMatch( /Popover[\s\S]*?onClose/ ); + 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 index e8ea2fe..2578d94 100644 --- a/tests/components/save-handler-scheduling.test.js +++ b/tests/components/save-handler-scheduling.test.js @@ -1,111 +1,120 @@ /** - * Save Handler and Data Threading Tests + * Save Handler Scheduling Tests * - * Tests for scheduling support in the save handler and timezone data threading. - * Verifies that handleSave passes date in the REST body, the schedule-specific - * snackbar displays correctly, timezone flows through the component tree, and - * no redirect occurs for 'future' status. + * Behavioral tests for the formatScheduleDate utility function in PressThisEditor. + * Verifies date formatting, timezone label appending, and error handling. * * @package press-this */ -const fs = require( 'fs' ); -const path = require( 'path' ); - -describe( 'Save Handler: scheduling support', () => { - let editorContent; - - beforeAll( () => { - const editorPath = path.resolve( - __dirname, - '../../src/components/PressThisEditor.js' - ); - editorContent = fs.readFileSync( editorPath, 'utf8' ); +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( 'handleSave includes date in the REST request body when options.date is provided', () => { - // The JSON.stringify body should include a date field sourced from options.date. - expect( editorContent ).toMatch( /date:\s*options\.date/ ); - - // Verify it is inside the JSON.stringify call alongside other body fields. - const bodyMatch = editorContent.match( - /body:\s*JSON\.stringify\(\s*\{([\s\S]*?)\}\s*\)/ - ); - expect( bodyMatch ).not.toBeNull(); - expect( bodyMatch[ 1 ] ).toContain( 'date' ); + test( 'appends timezone identifier when provided', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', 'America/New_York' ); + expect( result ).toContain( 'America/New_York' ); } ); - test( 'successful schedule response shows a snackbar with the formatted date', () => { - // Check for 'future' status detection in the success handler. - expect( editorContent ).toMatch( /status\s*===\s*'future'/ ); - - // Check for Intl.DateTimeFormat usage for date formatting. - expect( editorContent ).toContain( 'Intl.DateTimeFormat' ); + test( 'appends fixed-offset timezone string', () => { + const result = formatScheduleDate( '2026-06-15T14:30:00', 'UTC+2' ); + expect( result ).toContain( 'UTC+2' ); + } ); - // Check for the schedule-specific snackbar message using sprintf. - expect( editorContent ).toMatch( /Post scheduled for %s/ ); - expect( editorContent ).toMatch( - /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ - ); + 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( 'no redirect occurs when status is future (response has no redirect key)', () => { - // The existing success handler shows a notice when result.redirect is falsy. - // For 'future' status, the server returns no redirect, so the no-redirect - // branch executes. Verify the no-redirect branch sets a notice (not a redirect). - expect( editorContent ).toMatch( - /if\s*\(\s*result\.redirect\s*\)/ - ); - - // The else branch (no redirect) sets a notice -- this covers 'future' status. - expect( editorContent ).toMatch( - /setNotice\(\s*\{[\s\S]*?status:\s*'success'/ - ); + test( 'returns the raw string for unparseable input', () => { + const result = formatScheduleDate( 'not-a-date', 'UTC' ); + expect( result ).toBe( 'not-a-date' ); } ); -} ); -describe( 'Data Threading: timezone from App to components', () => { - let appContent; - let editorContent; - let headerContent; - - beforeAll( () => { - const appPath = path.resolve( __dirname, '../../src/App.js' ); - const editorPath = path.resolve( - __dirname, - '../../src/components/PressThisEditor.js' - ); - const headerPath = path.resolve( - __dirname, - '../../src/components/Header.js' - ); - appContent = fs.readFileSync( appPath, 'utf8' ); - editorContent = fs.readFileSync( editorPath, 'utf8' ); - headerContent = fs.readFileSync( headerPath, 'utf8' ); + test( 'handles space separator in datetime', () => { + const result = formatScheduleDate( '2026-06-15 14:30:00', '' ); + expect( result ).toMatch( /June/ ); + expect( result ).toMatch( /15/ ); } ); - test( 'App.js reads timezone from pressThisData and threads it to PressThisEditor and Header', () => { - // App.js reads data.timezone. - expect( appContent ).toContain( 'data.timezone' ); - - // App.js passes timezone prop to PressThisEditor. - expect( appContent ).toMatch( / { + 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 index e98ab4e..2336b90 100644 --- a/tests/components/scheduling-integration.test.js +++ b/tests/components/scheduling-integration.test.js @@ -2,238 +2,155 @@ * Scheduling Integration Tests * * Cross-cutting integration tests for the post-scheduling feature. - * Verifies the full workflow from UI through data threading to save handler, - * filling gaps not covered by the individual unit test files. + * Tests the interaction between Header scheduling utilities and + * PressThisEditor formatting, verifying the full data flow. * * @package press-this */ -const fs = require( 'fs' ); -const path = require( 'path' ); - -describe( 'Scheduling Integration', () => { - let headerContent; - let editorContent; - let appContent; - - beforeAll( () => { - headerContent = fs.readFileSync( - path.resolve( __dirname, '../../src/components/Header.js' ), - 'utf8' - ); - editorContent = fs.readFileSync( - path.resolve( - __dirname, - '../../src/components/PressThisEditor.js' - ), - 'utf8' - ); - appContent = fs.readFileSync( - path.resolve( __dirname, '../../src/App.js' ), - 'utf8' - ); +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( 'full scheduling workflow: menu item -> popover -> date selection -> confirm -> REST request -> snackbar', () => { - // 1. Schedule MenuItem exists and opens the popover. - expect( headerContent ).toMatch( /setIsScheduleOpen\(\s*true\s*\)/ ); - - // 2. DateTimePicker is rendered in the popover with onChange wired to setScheduleDate. - expect( headerContent ).toMatch( - /DateTimePicker[\s\S]*?onChange=\{\s*setScheduleDate\s*\}/ - ); - - // 3. Confirmation button calls handleScheduleConfirm. - expect( headerContent ).toMatch( - /onClick=\{[\s\S]*?handleScheduleConfirm/ - ); - - // 4. handleScheduleConfirm calls onSave('future', { date: scheduleDate }). - expect( headerContent ).toMatch( - /onSave\(\s*'future'\s*,\s*\{\s*date:\s*scheduleDate\s*\}\s*\)/ - ); - - // 5. App.js wires onSave to saveState.handleSave from PressThisEditor. - expect( appContent ).toMatch( /onSave=\{\s*saveState\.handleSave\s*\}/ ); - - // 6. PressThisEditor's handleSave includes date in the REST body. - expect( editorContent ).toMatch( /date:\s*options\.date/ ); - - // 7. On success with no redirect and 'future' status, snackbar is shown. - expect( editorContent ).toMatch( - /status\s*===\s*'future'\s*&&\s*options\.date/ - ); - expect( editorContent ).toMatch( /Post scheduled for %s/ ); - } ); - - test( 'post saved with future status does not trigger a redirect', () => { - // The save handler checks result.redirect first, and the future status - // branch is in an 'else if' -- meaning it only runs when there is NO redirect. - // This ensures scheduled posts stay in Press This instead of redirecting. - - // Verify the 'else if' chain: redirect check followed by future status check. - expect( editorContent ).toMatch( - /if\s*\(\s*result\.redirect\s*\)\s*\{[\s\S]*?\}\s*else\s+if\s*\(\s*status\s*===\s*'future'/ - ); - - // The future branch shows a snackbar instead of redirecting. - expect( editorContent ).toMatch( - /status\s*===\s*'future'[\s\S]*?setNotice\(\s*\{[\s\S]*?status:\s*'success'/ - ); - - // Verify that performSafeRedirect is NOT called in the future branch -- - // it only appears in the redirect branch. - const futureBlock = editorContent.match( - /else\s+if\s*\(\s*status\s*===\s*'future'\s*&&\s*options\.date\s*\)\s*\{([\s\S]*?)\}\s*else\s*\{/ - ); - expect( futureBlock ).not.toBeNull(); - expect( futureBlock[ 1 ] ).not.toContain( 'performSafeRedirect' ); - } ); - - test( 'Schedule menu item is hidden when canPublish is false', () => { - // The Schedule MenuGroup is wrapped in a conditional on capabilities.canPublish. - // Verify the conditional rendering pattern wraps the MenuGroup containing Schedule. - expect( headerContent ).toMatch( - /capabilities\.canPublish\s*&&\s*\(\s*\n?\s*/ - ); - - // Verify the Schedule/Reschedule label is inside that conditional block. - const conditionalMatch = headerContent.match( - /capabilities\.canPublish\s*&&\s*\(([\s\S]*?)<\/MenuGroup>/ - ); - expect( conditionalMatch ).not.toBeNull(); - expect( conditionalMatch[ 1 ] ).toContain( 'scheduleMenuLabel' ); - } ); - - test( 'snackbar shows correctly formatted date with timezone', () => { - // formatScheduleDate function exists and uses Intl.DateTimeFormat. - expect( editorContent ).toMatch( - /function\s+formatScheduleDate\s*\(\s*dateString\s*,\s*timezone\s*\)/ - ); - - // It formats in UTC to avoid browser-timezone reinterpretation. - expect( editorContent ).toMatch( - /timeZone:\s*'UTC'/ - ); - - // It appends the timezone identifier to the formatted date. - expect( editorContent ).toMatch( - /formatted\s*\}\s*\$\{\s*timezone\s*\}/ - ); - - // The formatted result is used in the snackbar message via sprintf. - expect( editorContent ).toMatch( - /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ - ); + 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( 'date exactly at the 1-minute buffer threshold is treated as not-future', () => { - // The isFutureDate function uses a strict greater-than comparison with ONE_MINUTE. - // It now accepts a tz parameter to compare in the site timezone. - const isFutureFn = headerContent.match( - /function\s+isFutureDate\s*\(\s*dateString\s*,\s*tz\s*\)\s*\{([\s\S]*?)\n\}/ - ); - expect( isFutureFn ).not.toBeNull(); - - const fnBody = isFutureFn[ 1 ]; - - // Uses getCurrentDateInTimezone for current time reference instead of Date.now(). - expect( fnBody ).toContain( 'getCurrentDateInTimezone' ); - - // Computes difference as selectedMs - nowMs. - expect( fnBody ).toMatch( /selectedMs\s*-\s*nowMs/ ); - - // Uses strict greater-than with ONE_MINUTE (not >=). - // This means a date exactly ONE_MINUTE from now returns false (not future). - expect( fnBody ).toMatch( />\s*ONE_MINUTE/ ); - expect( fnBody ).not.toMatch( />=\s*ONE_MINUTE/ ); + 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( 'popover re-opens cleanly after a previous schedule action', () => { - // After handleScheduleConfirm, the popover is closed. - expect( headerContent ).toMatch( - /handleScheduleConfirm[\s\S]*?setIsScheduleOpen\(\s*false\s*\)/ - ); - - // When the Schedule menu item is clicked again, the date resets - // to either the post's scheduled date or the current time. - const menuItemClick = headerContent.match( - /setIsScheduleOpen\(\s*true\s*\)[\s\S]*?setScheduleDate\(/ - ); - expect( menuItemClick ).not.toBeNull(); - - // The date is freshly computed via getCurrentDateInTimezone on re-open - // (not stale from the previous session). - expect( headerContent ).toMatch( - /setScheduleDate\(\s*\n?\s*postStatus\s*===\s*'future'\s*&&\s*postDate[\s\S]*?getCurrentDateInTimezone/ - ); + 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( 'DateTimePicker defaults to current date/time on first open', () => { - // The scheduleDate state initializer calls getCurrentDateInTimezone - // when there is no existing scheduled post. - expect( headerContent ).toMatch( - /useState\(\s*\(\)\s*=>\s*\{[\s\S]*?getCurrentDateInTimezone\(\s*timezone\s*\)/ - ); - - // getCurrentDateInTimezone uses Intl.DateTimeFormat with the site timezone. - expect( headerContent ).toMatch( - /function\s+getCurrentDateInTimezone\s*\(\s*tz\s*\)/ - ); - - // It formats in ISO 8601 pattern (YYYY-MM-DDTHH:MM:SS). - expect( headerContent ).toMatch( - /get\(\s*'year'\s*\)[\s\S]*?get\(\s*'month'\s*\)[\s\S]*?get\(\s*'day'\s*\)/ - ); - - // DateTimePicker receives scheduleDate as currentDate. - expect( headerContent ).toMatch( - /DateTimePicker[\s\S]*?currentDate=\{\s*scheduleDate\s*\}/ - ); - } ); + test( 'round-trip: parse -> check future -> format for a scheduled post', () => { + const scheduledDate = '2099-03-15T09:00:00'; + const tz = 'Asia/Tokyo'; - test( 'post status updates in App.js after scheduling', () => { - // App.js maintains postStatus and postDate as React state. - expect( appContent ).toMatch( /\[\s*postStatus\s*,\s*setPostStatus\s*\]/ ); - expect( appContent ).toMatch( /\[\s*postDate\s*,\s*setPostDate\s*\]/ ); + // Step 1: Parse the date. + const ms = parseNaiveToMs( scheduledDate ); + expect( ms ).not.toBeNaN(); - // App.js passes onPostStatusChange callback to PressThisEditor. - expect( appContent ).toMatch( - /onPostStatusChange=\{\s*handlePostStatusChange\s*\}/ - ); - - // App.js passes postStatus and postDate to Header from state (not static data). - expect( appContent ).toMatch( /postStatus=\{\s*postStatus\s*\}/ ); - expect( appContent ).toMatch( /postDate=\{\s*postDate\s*\}/ ); - - // PressThisEditor calls onPostStatusChange after a successful future save. - expect( editorContent ).toMatch( /onPostStatusChange\(/ ); - } ); - - test( 'schedule popover has accessible aria-label', () => { - // The Popover should have an aria-label for accessibility. - expect( headerContent ).toMatch( - /Popover[\s\S]*?aria-label=\{\s*__\(\s*'Schedule post'/ - ); - } ); + // Step 2: Check if it's in the future. + expect( isFutureDate( scheduledDate, tz ) ).toBe( true ); - test( 'snackbar message uses sprintf for translatable formatting', () => { - // The snackbar message should use sprintf with __() instead of a template literal. - expect( editorContent ).toContain( 'sprintf' ); - expect( editorContent ).toMatch( - /import\s*\{[^}]*sprintf[^}]*\}\s*from\s*'@wordpress\/i18n'/ - ); - expect( editorContent ).toMatch( - /sprintf\(\s*\n?\s*\/\*[\s\S]*?\*\/\s*\n?\s*__\(\s*'Post scheduled for %s\.'/ - ); + // 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( 'formatScheduleDate uses browser default locale for primary formatting', () => { - // The primary DateTimeFormat call should use undefined (browser default). - expect( editorContent ).toMatch( - /new\s+Intl\.DateTimeFormat\(\s*undefined\s*,\s*options\s*\)/ - ); - // The 'en-US' usage is only for deriving timezone abbreviation strings - // (not user-visible date formatting), which is acceptable. + 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 index 92d0798..e1e02e2 100644 --- a/tests/php/test-scheduling.php +++ b/tests/php/test-scheduling.php @@ -199,6 +199,44 @@ public function test_future_status_without_date_returns_error() { $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. */ From 87992ba9d2db2dcda6e7cdc78679326965ad65d2 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 14:06:14 -0500 Subject: [PATCH 6/7] fix: Address remaining Copilot review feedback --- class-wp-press-this-plugin.php | 4 ++-- src/components/PressThisEditor.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/class-wp-press-this-plugin.php b/class-wp-press-this-plugin.php index 2d079e5..9890d8d 100644 --- a/class-wp-press-this-plugin.php +++ b/class-wp-press-this-plugin.php @@ -1556,8 +1556,8 @@ public function html() { 'postId' => $post_ID, 'title' => $post_title, 'content' => $post_content, - 'postStatus' => get_post_status( $post_ID ), - 'postDate' => mysql_to_rfc3339( get_post( $post_ID )->post_date ), + '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' ), diff --git a/src/components/PressThisEditor.js b/src/components/PressThisEditor.js index ebf7239..8412484 100644 --- a/src/components/PressThisEditor.js +++ b/src/components/PressThisEditor.js @@ -235,7 +235,7 @@ function getWpRestBaseUrl( pressThisRestUrl ) { * in the user's preferred language rather than hardcoded to English. * * @param {string} dateString ISO date string to format. - * @param {string} timezone IANA timezone string (e.g., "America/New_York"). + * @param {string} timezone Timezone identifier (e.g., "America/New_York" or "UTC+2"). * @return {string} Human-readable formatted date. */ function formatScheduleDate( dateString, timezone ) { From b90c0e3f936e914fb5a873dc402f91ebeb106014 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Mon, 16 Mar 2026 17:02:04 -0500 Subject: [PATCH 7/7] Handle fixed-offset timezones in getCurrentDateInTimezone Intl.DateTimeFormat only accepts IANA zone names and "UTC", not fixed-offset strings like "UTC+2". Add an explicit path that parses the offset and applies it manually. Also normalize the catch fallback to return a naive datetime string (no Z suffix or milliseconds) so scheduled dates stay consistent. --- src/components/Header.js | 61 ++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/src/components/Header.js b/src/components/Header.js index 67bfd32..5c488eb 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -105,8 +105,26 @@ function getTimezoneAbbreviation( tz, date ) { * @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 new Date().toISOString(); + 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 { @@ -124,9 +142,11 @@ function getCurrentDateInTimezone( tz ) { 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' ) }`; + return `${ get( 'year' ) }-${ get( 'month' ) }-${ get( 'day' ) }T${ get( + 'hour' + ) }:${ get( 'minute' ) }:${ get( 'second' ) }`; } catch { - return new Date().toISOString(); + return formatNaive( new Date() ); } } @@ -251,7 +271,10 @@ export default function Header( { const undoShortcut = isMacOS() ? '\u2318Z' : 'Ctrl+Z'; const redoShortcut = isMacOS() ? '\u21E7\u2318Z' : 'Ctrl+Shift+Z'; - const timezoneAbbreviation = getTimezoneAbbreviation( timezone, scheduleDate ); + const timezoneAbbreviation = getTimezoneAbbreviation( + timezone, + scheduleDate + ); const isScheduleFuture = isFutureDate( scheduleDate, timezone ); const scheduleButtonLabel = isScheduleFuture ? __( 'Schedule', 'press-this' ) @@ -570,10 +593,7 @@ export default function Header( {
{ ( { onClose } ) => ( @@ -595,11 +615,17 @@ export default function Header( { { - setIsScheduleOpen( true ); + setIsScheduleOpen( + true + ); setScheduleDate( - postStatus === 'future' && postDate + postStatus === + 'future' && + postDate ? postDate - : getCurrentDateInTimezone( timezone ) + : getCurrentDateInTimezone( + timezone + ) ); onClose(); } } @@ -615,12 +641,13 @@ export default function Header( { { isScheduleOpen && ( - setIsScheduleOpen( false ) - } + onClose={ () => setIsScheduleOpen( false ) } placement="bottom-end" className="press-this-header__schedule-popover" - aria-label={ __( 'Schedule post', 'press-this' ) } + aria-label={ __( + 'Schedule post', + 'press-this' + ) } >