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;
+ }
+ ?>
+
+
+
+
+ $year_posts ) : ?>
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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( '', $output );
+ $this->assertStringNotContainsString( 'wp-on-this-day-scroll', $output );
+ $this->assertStringContainsString( '' . $last_year . '
', $output );
+ $this->assertStringContainsString( '' . $two_years_ago . '
', $output );
+ $this->assertStringContainsString( 'Pretending to meditate', $output );
+ $this->assertStringContainsString( 'Slow internet and good books', $output );
+ $this->assertStringContainsString( 'Late-night shipping log', $output );
+ $this->assertStringNotContainsString( 'wp-on-this-day-carousel', $output );
+ $this->assertStringNotContainsString( 'wp-on-this-day-post-share', $output );
+ }
+}