Publishing Calendar
Read-only WordPress editorial and publishing calendar plugin for scheduled posts and custom post types. Browse monthly schedules, filter post types, view authors, exact site-time publication dates, previews and missed-schedule diagnostics. Timezone-aware, permission-safe, accessible, and powered by FullCalendar.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/ildrm/wordpress-publishing-calendar/archive/refs/heads/main.zipRead-only WordPress scheduled publishing calendar. Version 1.0.0, WordPress 6.9+, PHP 8.1+. Current chronology: Gregorian.
Open Posts → Publishing Calendar. The month grid shows only future posts you may edit. Filter by post type and open a post for its exact site-time schedule, author, type, edit link, and preview link. WordPress owns all publishing behavior. There is no schedule editing, persistent plugin data, telemetry, or CDN dependency.
Architecture
WordPress infrastructure → scheduling domain → calendar/time abstraction → REST presentation adapter → Gregorian FullCalendar renderer
src/Domain: immutable UTC publication and repository/chronology contracts. No WP_Post or FullCalendar objects.src/Infrastructure/WordPress: site timezone, validated post types, bounded native future-post querying, object authorization, and native cron observations.src/Application: read-only retrieval use case.src/Calendar: Gregorian date grouping.src/Rest: strict visible-range validation and minimal presentation payload.src/Admin: page markup, page-scoped assets, translated configuration.assets/src: API wrapper, safe presentation helpers, accessible detail dialog, and Gregorian renderer. FullCalendar 7.1.1 vanilla API usesfullcalendar/daygridand the classic theme. Temporal 1.0.5 handles IANA/fixed-offset zones. No React application or interaction plugin.
The plugin's PSR-4-compatible runtime loader avoids a production Composer requirement. Activation and deactivation register/remove no persistent schedules or data. Uninstall does not delete WordPress data.
Development
Use Node 22.12+ (or a newer supported Node release), npm, Composer, PHP 8.1+, and PHP's zip extension. The committed lockfiles pin the complete dependency graphs.
npm ci
composer install
npm run build
npm run dev # asset watcher
npm run lint
npm test
composer lint
composer analyse
composer test
composer lint:fix applies WordPress formatting. PHPStan uses WordPress stubs at level 6, without a baseline. If a sandbox prohibits PHPStan's worker sockets, use vendor/bin/phpstan analyse --debug --memory-limit=512M to run it in one process.
The PHP lint rules use WordPress-Core and WordPress-Extra, including security and translation checks. PSR-4 class/file names are intentionally exempt from WordPress's hyphenated class filename convention. JavaScript linting uses ESLint; unit tests use Node's test runner. PHP tests use PHPUnit 10.5.
Real WordPress and browser tests
The following commands create and modify a disposable local test site, never a production site. Requirements: Docker, WP-CLI, PHP mysqli, and port 13396/8889 availability. The isolated MySQL container is named publishing-calendar-test-db; its credentials are public test-only values. WordPress lives in ignored .local/wordpress and the plugin is linked into it. Setup defaults to 6.9.4; PC_WP_VERSION=7.1.3 selects that version for a new test directory. CI covers PHP 8.1 / WordPress 6.9.4 and PHP 8.3 / WordPress 7.1.3.
bash scripts/setup-wordpress.sh
composer test:integration
npx playwright install chromium
npm run test:e2e
If browser downloads are unavailable, set PC_CHROME_PATH to an installed Chrome executable. Playwright starts a local PHP server and tests with browser timezone America/Los_Angeles while the site uses Europe/Berlin or +03:30. Tests cover real cookie/REST flow, navigation across a year, filtering, errors/retry, empty/loading states, overflow, counts, keyboard dialog use, focus restoration, axe accessibility, Persian RTL chronology, and page-scoped assets. Screenshots are saved in .local. Integration fixtures exercise administrators, editors, authors, contributors, subscribers, and anonymous users against actual WordPress capabilities and queries.
Stop the disposable database with docker stop publishing-calendar-test-db. To discard its data, remove only that named container and .local/wordpress. Do not point these tests at an existing WordPress installation.
REST endpoint
GET /wp-json/publishing-calendar/v1/events
Cookie authentication uses the standard X-WP-Nonce (wp_rest) header. WordPress application-password authentication also follows normal REST behavior. Baseline capability is edit_posts; each post additionally requires edit_post for its ID. Nonces do not grant authorization.
| Parameter | Meaning |
|---|---|
start |
Required RFC3339 timestamp with seconds and explicit Z/offset; inclusive. |
end |
Required RFC3339 timestamp; exclusive and later than start. |
post_type[] |
Optional array of currently eligible, authorized type names. Omit for all. |
Maximum range: 90 elapsed days. Maximum candidate posts: 2,000 per range. Queries request at most 2,001 IDs, use no_found_rows, avoid term/meta priming, and bulk-prime core post objects for object capability checks. A capacity overflow returns HTTP 413 without partial events. Invalid ranges/types return 400, denied access returns 401/403, and database query failures return 500. Successful responses use Cache-Control: no-store, private.
{
"events": [{
"id": "714",
"title": "Example publication",
"start": "2026-10-12T12:30:00Z",
"allDay": false,
"extendedProps": {
"postId": 714,
"postType": "post",
"postTypeLabel": "Post",
"author": "Editor",
"status": "future",
"editUrl": "https://example.test/wp-admin/post.php?post=714&action=edit",
"previewUrl": "https://example.test/?p=714&preview=true",
"missedSchedule": false,
"localDate": "2026-10-12",
"localTime": "14:30:00",
"localOffset": "+02:00"
}
}],
"diagnostics": {
"requestDrivenCronDisabled": false,
"missingCronEvents": 0,
"missedSchedules": 0
}
}
No content, excerpts, private metadata, or unauthorized object details are returned. Diagnostics inspect the native cron queue once and count only authorized loaded posts. They do not inspect or test external system schedulers. No separate count/diagnostics request is needed.
Time and chronology rules
post_date_gmt is the canonical instant; display/grouping use wp_timezone(). Fixed offsets and IANA DST rules come from PHP date APIs and Temporal, never manual offset arithmetic. A zero/invalid GMT timestamp cannot be treated as a canonical schedule; repair inconsistent post data through WordPress. Site timezone changes reinterpret the displayed clock time of the same UTC instant. Reload the page after changing the site timezone.
Start/end offsets are converted independently, so months containing DST changes have correct UTC boundaries. The backend sorts by UTC instant and ID, including repeated local clock times. The detail dialog includes the offset to disambiguate DST fallback. Browser timezone, locale, direction, and chronology are separate. Gregorian locale extensions prevent Persian/Arabic locale defaults from changing date formatting.
CalendarSystemInterface owns chronology-specific date grouping; EventPresenter adapts the domain for the renderer. calendar.js selects the Gregorian renderer, and gregorian-renderer.js owns FullCalendar. Future Solar Hijri/Islamic support requires a chronology implementation and a specialized renderer/date-grid adapter for actual month boundaries, leap years, and navigation. It must preserve the UTC domain, WordPress repository, and REST range contract. Changing FullCalendar locale alone cannot supply another chronology. No future renderer or selector is implemented in 1.0.0.
Public extension point
publishing_calendar_post_types filters a list of post-type names before discovery finishes. By default these are public types with administration UI. A filter may add a nonpublic editorial type, but every result must be registered, have show_ui, and grant its edit_posts capability to the current user. Attachments, revisions, and menu items remain excluded. Each object still requires edit_post.
add_filter( 'publishing_calendar_post_types', static function ( array $types ): array {
$types[] = 'editorial_article';
return $types;
} );
There are no query-argument or event-payload filters that can accidentally weaken status, range, or authorization constraints.
Internationalization and accessibility
All plugin UI strings are translated in PHP with publishing-calendar, then passed as configuration to JavaScript. This allows normal WordPress MO translations without a separate JavaScript catalog. FullCalendar locale data are bundled locally. Generate the POT with WP-CLI:
wp i18n make-pot . languages/publishing-calendar.pot --domain=publishing-calendar --exclude=node_modules,vendor,.local,build,tests,scripts --skip-js
The UI uses semantic filters/buttons, live loading/error announcements, textual missed indicators, keyboard-operable events, native modal dialog behavior with a focus fallback, Escape dismissal, focus restoration, and logical CSS properties. The fixed seven-column grid scrolls within a labeled keyboard-focusable region on narrow screens.
Release
npm run lint && npm test && composer check
composer test:integration && npm run test:e2e
npm run release
build/publishing-calendar-1.0.0.zip contains one publishing-calendar/ folder: bootstrap, uninstall, PHP source, compiled JS/CSS, third-party notices, POT, readme, changelog, and license. It excludes Node/Composer tooling, tests, local site, dependency directories, source JS, and Git. The ZIP uses a stable file order and timestamps and has a SHA-256 sidecar. Install this artifact on a clean site for activation/REST/admin verification. No Node or Composer is needed at runtime.
Executed release checks and the four final audits are recorded in docs/VERIFICATION.md.