diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css
index ab73f828f7067..4a84c4c637e90 100644
--- a/src/wp-admin/css/dashboard.css
+++ b/src/wp-admin/css/dashboard.css
@@ -1023,6 +1023,49 @@ body #dashboard-widgets .postbox form .submit {
top: 0;
}
+/* On This Day dashboard widget */
+
+#wp_dashboard_on_this_day .wp-on-this-day-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.wp-on-this-day-date {
+ padding: 2px 9px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
+ color: var(--wp-admin-theme-color-darker-10, #135e96);
+ background: rgba(var(--wp-admin-theme-color--rgb, 34, 113, 177), 0.08);
+ border-radius: 9999px;
+ white-space: nowrap;
+}
+
+#wp_dashboard_on_this_day h3 {
+ font-weight: 600;
+}
+
+#wp_dashboard_on_this_day li {
+ margin: 0;
+ padding: 0;
+}
+
+#wp_dashboard_on_this_day ul ul {
+ margin: 0 0 0 18px;
+ padding: 0;
+ list-style: disc;
+}
+
+#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-post-author {
+ color: #646970;
+}
+
/* Browse happy box */
#dashboard-widgets #dashboard_browser_nag.postbox .inside {
diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php
new file mode 100644
index 0000000000000..be85b46f0ab28
--- /dev/null
+++ b/src/wp-admin/includes/dashboard-on-this-day.php
@@ -0,0 +1,227 @@
+%s %s',
+ esc_html__( 'On This Day' ),
+ /* translators: Date format for the On This Day widget date, without year. See https://www.php.net/manual/datetime.format.php */
+ esc_html( wp_date( _x( 'F jS', 'on this day date format' ) ) )
+ ),
+ 'wp_dashboard_on_this_day'
+ );
+}
+
+/**
+ * Renders the On This Day dashboard widget.
+ *
+ * Outputs the matching posts grouped by publication year, newest year first.
+ *
+ * @since 7.1.0
+ *
+ * @global WP_Post $post Global post object.
+ */
+function wp_dashboard_on_this_day() {
+ global $post;
+
+ $posts = wp_dashboard_on_this_day_get_posts();
+
+ if ( empty( $posts ) ) {
+ return;
+ }
+
+ $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;
+ }
+ ?>
+
+ format( 'Y' );
+ $date_query = array(
+ 'relation' => 'AND',
+ array(
+ 'before' => array( 'year' => $year ),
+ ),
+ _wp_dashboard_on_this_day_date_query_clause( $today ),
+ );
+
+ $args = array(
+ 'post_type' => 'post',
+ 'post_status' => array( 'publish' ),
+ 'posts_per_page' => 10,
+ '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.
+ */
+ $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args );
+
+ $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
+ * @access private
+ *
+ * @param DateTimeInterface $date Date to build the clause for.
+ * @return array Date query clause.
+ */
+function _wp_dashboard_on_this_day_date_query_clause( $date ) {
+ $month = (int) $date->format( 'm' );
+ $day = (int) $date->format( 'd' );
+ $clause = array(
+ 'month' => $month,
+ 'day' => $day,
+ );
+
+ // Display leap day posts on Feb 28 in non leap years.
+ if (
+ 28 === $day
+ && 2 === $month
+ && false === (bool) $date->format( 'L' )
+ ) {
+ $clause = array(
+ 'relation' => 'OR',
+ $clause,
+ array(
+ 'month' => 2,
+ 'day' => 29,
+ ),
+ );
+ }
+
+ return $clause;
+}
+
+/**
+ * Renders a single linked post title for the On This Day dashboard widget.
+ *
+ * Must be called with the global post set up via `setup_postdata()`.
+ *
+ * @since 7.1.0
+ * @access private
+ */
+function _wp_dashboard_on_this_day_post() {
+ $title = get_the_title();
+
+ if ( '' === trim( $title ) ) {
+ $title = __( '(no title)' );
+ }
+
+ $author_id = (int) get_post_field( 'post_author', get_the_ID() );
+ $author_name = $author_id > 0 ? (string) get_the_author() : '';
+ $show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id;
+ ?>
+
+
+
+ ' . esc_html(
+ sprintf(
+ /* translators: %s: Post author's display name. */
+ __( 'by %s' ),
+ $author_name
+ )
+ ) . '';
+ ?>
+
+
+ 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_on_this_day_date_query_clause().
+ *
+ * @param string $date Date string.
+ * @return array Date query clause.
+ */
+ private static function get_date_query_clause( $date ) {
+ return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day_setup
+ */
+ public function test_setup_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_on_this_day_setup();
+
+ $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array();
+
+ $this->assertArrayNotHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day_setup
+ */
+ public function test_setup_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_on_this_day_setup();
+
+ $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array();
+
+ $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets );
+ $this->assertStringContainsString(
+ '' . esc_html( wp_date( 'F jS' ) ) . '',
+ $dashboard_widgets['wp_dashboard_on_this_day']['title']
+ );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day_setup
+ */
+ public function test_setup_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_on_this_day_setup();
+
+ $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array();
+
+ $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets );
+ }
+
+ /**
+ * @covers ::_wp_dashboard_on_this_day_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 ::_wp_dashboard_on_this_day_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 ::_wp_dashboard_on_this_day_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 ::wp_dashboard_on_this_day
+ */
+ public function test_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_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_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_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_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_on_this_day();
+ $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 );
+ $this->assertStringContainsString( 'by Guest Writer', $output );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_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_on_this_day();
+ $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 on this day:', $output );
+ $this->assertStringContainsString( '', $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 );
+ }
+
+ /**
+ * @covers ::wp_dashboard_on_this_day
+ * @covers ::wp_dashboard_on_this_day_get_posts
+ */
+ public function test_widget_limits_posts_to_ten() {
+ $user_id = self::factory()->user->create( array( 'role' => 'author' ) );
+ wp_set_current_user( $user_id );
+
+ for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) {
+ $this->create_matching_post( $user_id, 'Anniversary post ' . $years_ago, $years_ago );
+ }
+
+ ob_start();
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '10 posts have been published on this day:', $output );
+ $this->assertStringContainsString( 'Anniversary post 1<', $output );
+ $this->assertStringContainsString( 'Anniversary post 10<', $output );
+ $this->assertStringNotContainsString( 'Anniversary post 11', $output );
+ }
+}