diff --git a/src/wp-admin/css/on-this-day.css b/src/wp-admin/css/on-this-day.css new file mode 100644 index 0000000000000..be6ec03f868cb --- /dev/null +++ b/src/wp-admin/css/on-this-day.css @@ -0,0 +1,53 @@ +/* ============================================================================= + On This Day dashboard widget + ============================================================================= */ + +#wp_dashboard_on_this_day { + --wp-otd-widget-accent-rgb: var(--wp-admin-theme-color--rgb, 34, 113, 177); + --wp-otd-widget-muted: #646970; + --wp-otd-widget-pill: 9999px; + --wp-otd-widget-accent-dark: var(--wp-admin-theme-color-darker-10, #135e96); + --wp-otd-widget-accent-8: rgba(var(--wp-otd-widget-accent-rgb), 0.08); +} + +#wp_dashboard_on_this_day .wp-on-this-day-year { + margin: 0; + padding: 0; +} + +#wp_dashboard_on_this_day .wp-on-this-day-year + .wp-on-this-day-year { + margin-top: 16px; +} + +#wp_dashboard_on_this_day .wp-on-this-day-year-heading { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} + +#wp_dashboard_on_this_day .wp-on-this-day-title::after { + content: attr(data-wp-otd-window-label); + display: inline-block; + margin-left: 10px; + padding: 2px 9px; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--wp-otd-widget-accent-dark); + background: var(--wp-otd-widget-accent-8); + border-radius: var(--wp-otd-widget-pill); + white-space: nowrap; + vertical-align: 1px; +} + +#wp_dashboard_on_this_day .wp-on-this-day-posts { + margin: 0 0 0 18px; + padding: 0; + list-style: disc; +} + +#wp_dashboard_on_this_day .wp-on-this-day-post-author { + color: var(--wp-otd-widget-muted); +} diff --git a/src/wp-admin/includes/class-wp-dashboard-widget-on-this-day.php b/src/wp-admin/includes/class-wp-dashboard-widget-on-this-day.php new file mode 100644 index 0000000000000..7d621b8df27e7 --- /dev/null +++ b/src/wp-admin/includes/class-wp-dashboard-widget-on-this-day.php @@ -0,0 +1,355 @@ +%s', + esc_attr( self::get_window_label() ), + esc_html__( 'On This Day' ) + ), + array( __CLASS__, 'render_dashboard_widget' ) + ); + } + + /** + * Renders the dashboard widget output. + * + * The rendered HTML is cached per user and locale. The cache salt + * incorporates the site date and the posts group's + * `last_changed` token, so any post mutation (publish, edit, delete, + * trash) automatically invalidates the entry on the next read, and + * entries roll over naturally at midnight. + * + * Note: I made the trade-off to ignore `date_format` and `time_format` + * option changes. They do not bust the cache; stale date strings clear + * on the next post mutation or at midnight. + * + * @since 7.1.0 + */ + public static function render_dashboard_widget() { + $user_id = get_current_user_id(); + + $cache_key = sprintf( + 'render_otd_widget:v%d:%d:%s', + self::CACHE_VERSION, + $user_id, + determine_locale() + ); + $cache_salt = array( + current_time( 'Y-m-d' ), + wp_cache_get_last_changed( 'posts' ), + ); + + $cached = wp_cache_get_salted( $cache_key, self::CACHE_GROUP, $cache_salt ); + if ( ! is_string( $cached ) ) { + $posts = self::get_cached_posts( $user_id ); + + if ( empty( $posts ) ) { + return; + } + + ob_start(); + self::render_posts( $posts ); + $cached = ob_get_clean(); + + wp_cache_set_salted( $cache_key, $cached, self::CACHE_GROUP, $cache_salt, DAY_IN_SECONDS ); + } + + echo '
'; + // Already escaped at write time by the render_* methods below. + echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + } + + /** + * Retrieves published posts from all authors that were published on this + * calendar day in previous years. + * + * The date constraint matches today's month and day, combined with a + * `before` clause anchored to January 1 of the current year. + * + * @since 7.1.0 + * + * @param int $user_id Current user ID for filter context. + * @return WP_Post[] Array of posts ordered by newest first. + */ + public static function get_posts( $user_id ) { + $today = current_datetime(); + $year = (int) $today->format( 'Y' ); + $date_query = array( + 'relation' => 'AND', + array( + 'before' => array( 'year' => $year ), + ), + self::get_date_query_clause( $today ), + ); + + $args = array( + 'post_type' => 'post', + 'post_status' => array( 'publish' ), + 'posts_per_page' => self::POSTS_PER_PAGE, + 'ignore_sticky_posts' => true, + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'date_query' => $date_query, + ); + + /** + * Filters the arguments used to query posts for the On This Day dashboard widget. + * + * @since 7.1.0 + * + * @param array $args WP_Query arguments. + * @param int $user_id Current user ID. + */ + $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args, $user_id ); + + $query = new WP_Query( $args ); + + return $query->posts; + } + + /** + * Builds the date query clause for today's anniversary date. + * + * On February 28 in a non-leap year, February 29 posts are included so + * leap-day anniversaries still appear. + * + * @since 7.1.0 + * + * @param DateTimeInterface $date Date to build the clause for. + * @return array Date query clause. + */ + protected static function get_date_query_clause( $date ) { + $month = (int) $date->format( 'm' ); + $day = (int) $date->format( 'd' ); + $clause = array( + 'month' => $month, + 'day' => $day, + ); + + if ( 2 !== $month || 28 !== $day || $date->format( 'L' ) ) { + return $clause; + } + + return array( + 'relation' => 'OR', + $clause, + array( + 'month' => 2, + 'day' => 29, + ), + ); + } + + /** + * Determines whether posts are available for the widget. + * + * @since 7.1.0 + * + * @return bool True when matching posts exist, false otherwise. + */ + public static function has_posts() { + return ! empty( self::get_cached_posts( get_current_user_id() ) ); + } + + /** + * Retrieves cached posts for the widget. + * + * @since 7.1.0 + * + * @param int $user_id Current user ID for cache and filter context. + * @return WP_Post[] Array of posts ordered by newest first. + */ + protected static function get_cached_posts( $user_id ) { + $cache_salt = array( + current_time( 'Y-m-d' ), + wp_cache_get_last_changed( 'posts' ), + ); + $cache_key = sprintf( + 'query_posts:v%d:%d', + self::CACHE_VERSION, + (int) $user_id + ); + + $cached = wp_cache_get_salted( $cache_key, self::CACHE_GROUP, $cache_salt ); + if ( is_array( $cached ) ) { + return $cached; + } + + $posts = self::get_posts( $user_id ); + + wp_cache_set_salted( $cache_key, $posts, self::CACHE_GROUP, $cache_salt, DAY_IN_SECONDS ); + + return $posts; + } + + /** + * Renders the post list grouped by publication year. + * + * Outputs rendered HTML that has already been escaped at write time. + * Callers must echo the captured buffer as-is to avoid double-escaping. + * + * @since 7.1.0 + * + * @param WP_Post[] $posts Posts to render, most recent first. + */ + protected static function render_posts( $posts ) { + global $post; + + $posts_by_year = array(); + $post_count = count( $posts ); + + foreach ( $posts as $current_post ) { + $year = get_the_date( 'Y', $current_post ); + + if ( ! isset( $posts_by_year[ $year ] ) ) { + $posts_by_year[ $year ] = array(); + } + + $posts_by_year[ $year ][] = $current_post; + } + ?> +

+ +

+ + +
  • + + + + + + +
  • + add( 'l10n', "/wp-admin/css/l10n$suffix.css" ); $styles->add( 'code-editor', "/wp-admin/css/code-editor$suffix.css", array( 'wp-codemirror' ) ); $styles->add( 'site-health', "/wp-admin/css/site-health$suffix.css" ); + $styles->add( 'on-this-day', "/wp-admin/css/on-this-day$suffix.css" ); $styles->add( 'wp-admin', false, array( 'dashicons', 'common', 'forms', 'admin-menu', 'dashboard', 'list-tables', 'edit', 'revisions', 'media', 'themes', 'about', 'nav-menus', 'widgets', 'site-icon', 'l10n', 'wp-base-styles' ) ); @@ -1854,6 +1855,7 @@ function wp_default_styles( $styles ) { 'customize-preview', 'login', 'site-health', + 'on-this-day', 'wp-empty-template-alert', // Includes CSS. 'buttons', diff --git a/tests/phpunit/tests/admin/wpOnThisDay.php b/tests/phpunit/tests/admin/wpOnThisDay.php new file mode 100644 index 0000000000000..647151b14c9c5 --- /dev/null +++ b/tests/phpunit/tests/admin/wpOnThisDay.php @@ -0,0 +1,311 @@ +setAccessible( true ); + } + } + + public function tear_down() { + unset( $GLOBALS['wp_meta_boxes']['dashboard'] ); + wp_dequeue_style( 'on-this-day' ); + + parent::tear_down(); + } + + /** + * Sets up the globals needed to register dashboard widgets. + */ + private function set_up_dashboard_screen() { + if ( ! function_exists( 'wp_add_dashboard_widget' ) ) { + require_once ABSPATH . 'wp-admin/includes/dashboard.php'; + } + + set_current_screen( 'dashboard' ); + + $GLOBALS['wp_meta_boxes']['dashboard'] = array(); + + wp_dequeue_style( 'on-this-day' ); + } + + /** + * Creates a published post on the widget's prior-year calendar day. + * + * @param int $author_id Author ID. + * @param string $title Post title. + * @param int $years_ago Number of years before today. + * @param string $time Post time. + * @return int Post ID. + */ + private function create_matching_post( + $author_id, + $title = 'A memory from last year', + $years_ago = 1, + $time = '12:00:00' + ) { + $post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time; + + return self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ) + ); + } + + /** + * Creates a published post near, but not on, today's prior-year calendar day. + * + * @param int $author_id Author ID. + * @param string $title Post title. + * @param int $day_offset Number of days from today's prior-year calendar day. + * @return int Post ID. + */ + private function create_nearby_post( $author_id, $title = 'Almost a memory', $day_offset = 1 ) { + $post_date = current_datetime() + ->modify( '-1 year' ) + ->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' ) + ->format( 'Y-m-d' ) . ' 12:00:00'; + + return self::factory()->post->create( + array( + 'post_author' => $author_id, + 'post_date' => $post_date, + 'post_date_gmt' => get_gmt_from_date( $post_date ), + 'post_status' => 'publish', + 'post_title' => $title, + ) + ); + } + + /** + * Invokes WP_Dashboard_Widget_On_This_Day::get_date_query_clause(). + * + * @param string $date Date string. + * @return array Date query clause. + */ + private static function get_date_query_clause( $date ) { + return self::$get_date_query_clause->invoke( null, new DateTimeImmutable( $date, wp_timezone() ) ); + } + + /** + * @covers ::register_widget + */ + public function test_register_widget_does_not_add_dashboard_widget_without_matching_posts() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + WP_Dashboard_Widget_On_This_Day::register_widget(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayNotHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + $this->assertFalse( wp_style_is( 'on-this-day', 'enqueued' ) ); + } + + /** + * @covers ::register_widget + * @covers ::get_window_label + */ + public function test_register_widget_adds_dashboard_widget_with_matching_posts() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_matching_post( $user_id ); + + WP_Dashboard_Widget_On_This_Day::register_widget(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + $this->assertStringContainsString( + 'data-wp-otd-window-label="' . esc_attr( WP_Dashboard_Widget_On_This_Day::get_window_label() ) . '"', + $dashboard_widgets['wp_dashboard_on_this_day']['title'] + ); + $this->assertTrue( wp_style_is( 'on-this-day', 'enqueued' ) ); + } + + /** + * @covers ::register_widget + */ + public function test_register_widget_adds_dashboard_widget_with_matching_post_from_another_author() { + $this->set_up_dashboard_screen(); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $other_user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_matching_post( $other_user_id ); + + WP_Dashboard_Widget_On_This_Day::register_widget(); + + $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array(); + + $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets ); + $this->assertTrue( wp_style_is( 'on-this-day', 'enqueued' ) ); + } + + /** + * @covers ::get_date_query_clause + */ + public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() { + $clause = self::get_date_query_clause( '2023-02-28 12:00:00' ); + + $this->assertSame( + array( + 'relation' => 'OR', + array( + 'month' => 2, + 'day' => 28, + ), + array( + 'month' => 2, + 'day' => 29, + ), + ), + $clause + ); + } + + /** + * @covers ::get_date_query_clause + */ + public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() { + $clause = self::get_date_query_clause( '2024-02-28 12:00:00' ); + + $this->assertSame( + array( + 'month' => 2, + 'day' => 28, + ), + $clause + ); + } + + /** + * @covers ::get_date_query_clause + */ + public function test_get_date_query_clause_matches_february_29_on_leap_day() { + $clause = self::get_date_query_clause( '2024-02-29 12:00:00' ); + + $this->assertSame( + array( + 'month' => 2, + 'day' => 29, + ), + $clause + ); + } + + /** + * @covers ::render_dashboard_widget + */ + public function test_render_dashboard_widget_outputs_nothing_without_matching_posts() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + ob_start(); + WP_Dashboard_Widget_On_This_Day::render_dashboard_widget(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * @covers ::render_dashboard_widget + */ + public function test_render_dashboard_widget_ignores_nearby_prior_year_posts() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + $this->create_nearby_post( $user_id ); + + ob_start(); + WP_Dashboard_Widget_On_This_Day::render_dashboard_widget(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * @covers ::render_dashboard_widget + */ + public function test_render_dashboard_widget_labels_posts_from_other_authors() { + $user_id = self::factory()->user->create( + array( + 'display_name' => 'Current Writer', + 'role' => 'author', + ) + ); + $other_user_id = self::factory()->user->create( + array( + 'display_name' => 'Guest Writer', + 'role' => 'author', + ) + ); + wp_set_current_user( $user_id ); + + $this->create_matching_post( $user_id, 'A note from me' ); + $this->create_matching_post( $other_user_id, 'A note from someone else' ); + + ob_start(); + WP_Dashboard_Widget_On_This_Day::render_dashboard_widget(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'A note from me', $output ); + $this->assertStringNotContainsString( 'by Current Writer', $output ); + $this->assertStringContainsString( 'A note from someone else', $output ); + $this->assertStringContainsString( 'by Guest Writer', $output ); + } + + /** + * @covers ::render_dashboard_widget + */ + public function test_render_dashboard_widget_groups_posts_by_year() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + wp_set_current_user( $user_id ); + + $this->create_matching_post( $user_id, 'Pretending to meditate', 1, '12:00:00' ); + $this->create_matching_post( $user_id, 'Slow internet and good books', 1, '11:00:00' ); + $this->create_matching_post( $user_id, 'Late-night shipping log', 2, '12:00:00' ); + + ob_start(); + WP_Dashboard_Widget_On_This_Day::render_dashboard_widget(); + $output = ob_get_clean(); + + $last_year = current_datetime()->modify( '-1 year' )->format( 'Y' ); + $two_years_ago = current_datetime()->modify( '-2 years' )->format( 'Y' ); + + $this->assertStringContainsString( '3 posts have been published in previous years:', $output ); + $this->assertStringContainsString( '