diff --git a/README.md b/README.md index 2dc299c..e0c9a2e 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,23 @@ Dismissal is keyed by ID, not version: a user who dismissed 1.0.0 does not see 1 1. The request is not for wp-admin: `is_admin()` is false. 2. Another copy of the package already rendered the same banner ID on this request. -3. The current time is before `start` or at or after `end`. +3. The current time is before `start` or at or after `end`, unless the schedule filter says otherwise. 4. The current user dismissed it. 5. The callable was passed and returned false. The callable runs last and only when it can still matter, so an expensive check costs nothing on a banner that would not show anyway. +Filter `nx_banners_scheduled:{id}` to decide step 3 yourself, such as to see a banner in QA before it +starts, without moving the site's clock: + +```php +add_filter( 'nx_banners_scheduled:nexcess-black-friday-2026', '__return_true' ); +``` + +It receives whether the banner is inside its schedule. Return false to hide a banner inside it. The +filter applies only when the plugin that registered the banner bundles version 1.1.0 or later. + When two plugins render the same banner ID on one request, the first to call `render()` prints it and the other prints nothing. Different IDs each render. @@ -238,6 +248,7 @@ Everything shared between plugins is therefore reached through a string key: | `nx-banners-dismiss` | Dismiss query argument carrying the banner ID. The nonce action is `nx-banners-dismiss:{id}`. | | `nx-banners-nonce` | Nonce query argument, named so it cannot replace a `_wpnonce` already in the page URL. | | `nx_banners_rendered:{id}` | Action fired when a banner renders; later copies check `did_action()` and stand down. | +| `nx_banners_scheduled:{id}` | Filter: whether the banner is inside its schedule, as a bool. | | `nx-banners-{id}` | Handle of the banner's stylesheet in WordPress's style registry. | A registered banner is called only through its methods, never checked by class: `version()` while diff --git a/src/Banner.php b/src/Banner.php index ba96d90..a810e01 100644 --- a/src/Banner.php +++ b/src/Banner.php @@ -16,6 +16,12 @@ final class Banner { */ private const RENDERED_ACTION = 'nx_banners_rendered:'; + /** + * Prefix of the filter on whether a banner is in its schedule. Shared across copies, so it applies + * whichever copy renders the ID. + */ + private const SCHEDULED_FILTER = 'nx_banners_scheduled:'; + /** * Prefix of the handle a banner's stylesheet registers under. */ @@ -140,8 +146,11 @@ private function printTemplate( ?string $template, BannerFields $fields, string private function isScheduled(): bool { $now = time(); - return ( $this->start === null || $now >= $this->start->getTimestamp() ) + $scheduled = ( $this->start === null || $now >= $this->start->getTimestamp() ) && ( $this->end === null || $now < $this->end->getTimestamp() ); + + // Lets QA show a banner outside its schedule, or hide one inside it, without moving the clock. + return (bool) apply_filters( self::SCHEDULED_FILTER . $this->id, $scheduled ); } /** diff --git a/src/Package.php b/src/Package.php index cbca87e..f3f3a5d 100644 --- a/src/Package.php +++ b/src/Package.php @@ -10,6 +10,6 @@ final class Package { /** * Bump with every release: the bundled stylesheet's URL carries it, so browsers fetch the new file. */ - public const VERSION = '1.0.0'; + public const VERSION = '1.1.0'; } diff --git a/tests/BannerTest.php b/tests/BannerTest.php index 86cf356..db25dc1 100644 --- a/tests/BannerTest.php +++ b/tests/BannerTest.php @@ -3,6 +3,7 @@ namespace Nexcess\Banners\Tests; use Brain\Monkey\Actions; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use DateTimeImmutable; use DateTimeZone; @@ -258,6 +259,34 @@ public function testTheScheduleComparesMomentsNotWallClocksAcrossTimeZones(): vo $this->assertNotSame( '', $this->render( $banner ) ); } + /** + * QA can see a banner before it starts without moving the site's clock. + */ + public function testTheScheduleFilterCanShowABannerBeforeItsStart(): void { + $this->stubDismissed( [] ); + $this->stubRenderable(); + Filters\expectApplied( 'nx_banners_scheduled:promo' )->once()->with( false )->andReturn( true ); + + $this->assertNotSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, new DateTimeImmutable( '+1 hour' ) ) ) ); + } + + public function testTheScheduleFilterCanHideABannerInsideItsSchedule(): void { + $this->stubDismissed( [] ); + Filters\expectApplied( 'nx_banners_scheduled:promo' )->once()->with( true )->andReturn( false ); + + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, new DateTimeImmutable( '-1 hour' ) ) ) ); + } + + /** + * The filter is per banner ID, so faking one banner's schedule never moves another's. + */ + public function testTheScheduleFilterOfAnotherBannerChangesNothing(): void { + $this->stubDismissed( [] ); + Filters\expectApplied( 'nx_banners_scheduled:other' )->never(); + + $this->assertSame( '', $this->render( new Banner( 'promo', '1.0.0', new BannerFields(), self::PACKAGE_URL, new DateTimeImmutable( '+1 hour' ) ) ) ); + } + /** * Every plugin bundles its own prefixed copy, so the second Banner here stands in for another * plugin rendering the same campaign later on the same request.