diff --git a/lib/experimental/on-this-day/on-this-day.php b/lib/experimental/on-this-day/on-this-day.php
new file mode 100644
index 00000000000000..3d7c5f2827d70a
--- /dev/null
+++ b/lib/experimental/on-this-day/on-this-day.php
@@ -0,0 +1,73 @@
+ __( 'Limit response to posts published on the given month and day (MM-DD), regardless of year.', 'gutenberg' ),
+ 'type' => 'string',
+ 'pattern' => GUTENBERG_ON_THIS_DAY_PATTERN,
+ );
+
+ return $params;
+}
+add_filter( 'rest_post_collection_params', 'gutenberg_on_this_day_register_collection_param' );
+
+/**
+ * Translates the `on_this_day` REST parameter into a `date_query` clause.
+ *
+ * Skips the translation when the parameter is missing or fails the schema
+ * pattern. The schema-level validation in `rest_post_collection_params`
+ * normally catches invalid input before this filter runs, but the
+ * defensive check keeps the filter safe for callers that bypass the
+ * controller's argument validation.
+ *
+ * @param array $args `WP_Query` arguments built by the controller.
+ * @param WP_REST_Request $request REST request being handled.
+ * @return array `WP_Query` arguments, extended with a `date_query` clause when applicable.
+ */
+function gutenberg_on_this_day_apply_query( $args, $request ) {
+ $value = $request['on_this_day'] ?? null;
+
+ if ( ! is_string( $value ) || ! preg_match( '/' . GUTENBERG_ON_THIS_DAY_PATTERN . '/', $value ) ) {
+ return $args;
+ }
+
+ [ $month, $day ] = array_map( 'intval', explode( '-', $value ) );
+
+ if ( ! isset( $args['date_query'] ) || ! is_array( $args['date_query'] ) ) {
+ $args['date_query'] = array();
+ }
+
+ $args['date_query'][] = array(
+ 'month' => $month,
+ 'day' => $day,
+ );
+
+ return $args;
+}
+
+add_filter( 'rest_post_query', 'gutenberg_on_this_day_apply_query', 10, 2 );
diff --git a/lib/load.php b/lib/load.php
index b9a24113a33e78..723d68bfda069f 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -240,4 +240,5 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/dashboard-widgets/widget-types.php';
require __DIR__ . '/experimental/dashboard-widgets/dashboard-layout.php';
require __DIR__ . '/experimental/dashboard-widgets/default-layout-seed.php';
+ require __DIR__ . '/experimental/on-this-day/on-this-day.php';
}
diff --git a/phpunit/experimental/on-this-day/on-this-day-test.php b/phpunit/experimental/on-this-day/on-this-day-test.php
new file mode 100644
index 00000000000000..e06ef141e5858d
--- /dev/null
+++ b/phpunit/experimental/on-this-day/on-this-day-test.php
@@ -0,0 +1,158 @@
+
+ */
+ protected static $post_ids = array();
+
+ public static function wpSetUpBeforeClass( $factory ) {
+ self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
+
+ self::$post_ids['oldest_may_11'] = $factory->post->create(
+ array(
+ 'post_status' => 'publish',
+ 'post_date' => '2018-05-11 10:00:00',
+ 'post_title' => 'Oldest May 11',
+ )
+ );
+ self::$post_ids['newer_may_11'] = $factory->post->create(
+ array(
+ 'post_status' => 'publish',
+ 'post_date' => '2020-05-11 10:00:00',
+ 'post_title' => 'Newer May 11',
+ )
+ );
+ self::$post_ids['other_day'] = $factory->post->create(
+ array(
+ 'post_status' => 'publish',
+ 'post_date' => '2019-07-04 10:00:00',
+ 'post_title' => 'Independence Day',
+ )
+ );
+ }
+
+ public function set_up() {
+ parent::set_up();
+ wp_set_current_user( self::$subscriber_id );
+ }
+
+ public function tear_down() {
+ wp_set_current_user( 0 );
+ parent::tear_down();
+ }
+
+ public function test_collection_param_is_advertised_in_schema() {
+ $routes = rest_get_server()->get_routes();
+ $this->assertArrayHasKey( self::ROUTE, $routes );
+
+ $get_route_args = null;
+ foreach ( $routes[ self::ROUTE ] as $route_handler ) {
+ if ( in_array( 'GET', (array) $route_handler['methods'], true ) || ! empty( $route_handler['methods']['GET'] ) ) {
+ $get_route_args = $route_handler['args'];
+ break;
+ }
+ }
+
+ $this->assertNotNull( $get_route_args );
+ $this->assertArrayHasKey( 'on_this_day', $get_route_args );
+ $this->assertSame( 'string', $get_route_args['on_this_day']['type'] );
+ $this->assertSame( GUTENBERG_ON_THIS_DAY_PATTERN, $get_route_args['on_this_day']['pattern'] );
+ }
+
+ public function test_filter_returns_only_posts_matching_month_day() {
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+ $request->set_param( 'on_this_day', '05-11' );
+ $request->set_param( 'orderby', 'date' );
+ $request->set_param( 'order', 'asc' );
+
+ $response = rest_do_request( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $ids = array_column( $response->get_data(), 'id' );
+ sort( $ids );
+
+ $expected = array( self::$post_ids['oldest_may_11'], self::$post_ids['newer_may_11'] );
+ sort( $expected );
+
+ $this->assertSame( $expected, $ids );
+ }
+
+ public function test_orderby_ascending_returns_oldest_match_first() {
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+ $request->set_param( 'on_this_day', '05-11' );
+ $request->set_param( 'orderby', 'date' );
+ $request->set_param( 'order', 'asc' );
+ $request->set_param( 'per_page', 1 );
+
+ $response = rest_do_request( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertCount( 1, $data );
+ $this->assertSame( self::$post_ids['oldest_may_11'], $data[0]['id'] );
+ }
+
+ public function test_no_match_returns_empty_collection() {
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+ $request->set_param( 'on_this_day', '02-29' );
+
+ $response = rest_do_request( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $response->get_data() );
+ }
+
+ public function test_missing_param_returns_all_published_posts() {
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+
+ $response = rest_do_request( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $ids = array_column( $response->get_data(), 'id' );
+ $this->assertContains( self::$post_ids['oldest_may_11'], $ids );
+ $this->assertContains( self::$post_ids['newer_may_11'], $ids );
+ $this->assertContains( self::$post_ids['other_day'], $ids );
+ }
+
+ public function test_invalid_format_is_rejected_with_400() {
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+ $request->set_param( 'on_this_day', '2026-05-11' );
+
+ $response = rest_do_request( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ public function test_query_filter_ignores_invalid_value_when_called_directly() {
+ $args = array( 'post_type' => 'post' );
+ $request = new WP_REST_Request( 'GET', self::ROUTE );
+ $request->set_param( 'on_this_day', 'not-a-date' );
+
+ $filtered = gutenberg_on_this_day_apply_query( $args, $request );
+
+ $this->assertSame( $args, $filtered );
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index dba162ff1cee44..b8edb76a36f853 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -93,7 +93,8 @@
{ "path": "storybook" },
{ "path": "test/e2e" },
{ "path": "test/storybook-playwright" },
- { "path": "test/performance" }
+ { "path": "test/performance" },
+ { "path": "widgets" }
],
"files": []
}
diff --git a/widgets/on-this-day/components/on-this-day-view/index.ts b/widgets/on-this-day/components/on-this-day-view/index.ts
new file mode 100644
index 00000000000000..e120b96f51a1c0
--- /dev/null
+++ b/widgets/on-this-day/components/on-this-day-view/index.ts
@@ -0,0 +1 @@
+export { OnThisDayView, type BackgroundEffect } from './on-this-day-view';
diff --git a/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx
new file mode 100644
index 00000000000000..85d0e1473c5d1e
--- /dev/null
+++ b/widgets/on-this-day/components/on-this-day-view/on-this-day-view.tsx
@@ -0,0 +1,155 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { decodeEntities } from '@wordpress/html-entities';
+import { humanTimeDiff } from '@wordpress/date';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { calendar } from '@wordpress/icons';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { EmptyState, Link, Stack, Text, Button } from '@wordpress/ui';
+import { addQueryArgs } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import type { UseOnThisDayPostResult } from '../../hooks/use-on-this-day-post';
+import styles from './style.module.css';
+
+/**
+ * Named background filter presets. The widget exposes the chosen preset
+ * as an attribute; the surface picks the preset and the view resolves it
+ * to a CSS filter expression.
+ */
+export type BackgroundEffect =
+ | 'vintage'
+ | 'dim-saturate'
+ | 'dim-blur'
+ | 'grayscale'
+ | 'none';
+
+export const BACKGROUND_EFFECTS: Record< BackgroundEffect, string > = {
+ vintage: 'sepia(0.3) brightness(0.55) saturate(0.9)',
+ 'dim-saturate': 'brightness(0.55) saturate(1.15)',
+ 'dim-blur': 'brightness(0.5) blur(2px)',
+ grayscale: 'grayscale(0.4) brightness(0.55)',
+ none: 'none',
+};
+
+/**
+ * View props: result of the data hook plus the background filter preset.
+ */
+export interface OnThisDayViewProps extends UseOnThisDayPostResult {
+ effect?: BackgroundEffect;
+}
+
+/**
+ * Presentation for the `On This Day` widget. Decouples chrome and data
+ * fetching from the layout so Storybook and tests can drive each branch
+ * directly.
+ *
+ * @param {OnThisDayViewProps} props - The props for the `On This Day` widget.
+ * @return {React.ReactNode} - The `On This Day` widget.
+ */
+export function OnThisDayView( {
+ post,
+ isResolving,
+ hasAnyPosts,
+ effect = 'dim-blur',
+}: OnThisDayViewProps ): React.ReactNode {
+ if ( isResolving ) {
+ return (
+
+ { __( 'Loading…' ) }
+
+ );
+ }
+
+ if ( ! post ) {
+ return (
+
+
+
+ { __( 'Nothing on this day yet' ) }
+
+
+ { __( 'Your blogging memories are still being made.' ) }
+
+ { __( 'Check back again soon.' ) }
+
+
+ { ! hasAnyPosts && (
+
+
+ }
+ >
+ { __( 'Write your first post' ) }
+
+
+ ) }
+
+ );
+ }
+
+ const hasImage = !! post.featuredImageUrl;
+ const filterValue = BACKGROUND_EFFECTS[ effect ] ?? BACKGROUND_EFFECTS.none;
+ const relativeTime = sprintf(
+ /* translators: %s: Human-readable time difference, e.g. "2 days ago". */
+ __( 'On this day, %s' ),
+ humanTimeDiff( post.date )
+ );
+ const containerStyle = hasImage
+ ? ( {
+ '--on-this-day-bg-image': `url(${ post.featuredImageUrl })`,
+ '--on-this-day-bg-filter': filterValue,
+ } as React.CSSProperties )
+ : undefined;
+
+ return (
+
+
+ { relativeTime }
+
+
+ { decodeEntities( post.title.rendered ) }
+
+
+ { post.commentCount > 0 && (
+
+
+ { sprintf(
+ /* translators: %d: number of comments */
+ _n(
+ '%d comment',
+ '%d comments',
+ post.commentCount
+ ),
+ post.commentCount
+ ) }
+
+
+ ) }
+
+
+ );
+}
diff --git a/widgets/on-this-day/components/on-this-day-view/style.module.css b/widgets/on-this-day/components/on-this-day-view/style.module.css
new file mode 100644
index 00000000000000..54f1cbee7f065a
--- /dev/null
+++ b/widgets/on-this-day/components/on-this-day-view/style.module.css
@@ -0,0 +1,70 @@
+.container {
+ position: relative;
+ overflow: hidden;
+ isolation: isolate;
+ min-height: 240px;
+ height: 100%;
+}
+
+.no-posts-today {
+ height: 100%;
+ justify-content: center;
+ align-items: center;
+ max-width: 100%;
+}
+
+/* wp-admin's global `a` styles sit outside CSS layers and would beat
+ * the Button's layered rules. Restore them at higher specificity. */
+.no-posts-today a:any-link {
+ color: var(--wp-ui-button-foreground-color);
+ text-decoration: none;
+}
+
+.no-posts-today a:any-link:hover,
+.no-posts-today a:any-link:focus,
+.no-posts-today a:any-link:active {
+ color: var(--wp-ui-button-foreground-color-active);
+ text-decoration: none;
+}
+
+
+.header {
+ padding: var(--wpds-dimension-padding-lg);
+ border-start-start-radius: var(--wpds-border-radius-lg);
+ border-start-end-radius: var(--wpds-border-radius-lg);
+}
+
+/* Glass card: backdrop-filter dims only the pixels behind the text,
+ * so contrast holds on any image. Foreground uses
+ * `fg-interactive-neutral-strong` because the design system has no
+ * `fg-content-neutral-strong` variant. */
+.with-image .header {
+ --_gcd-heading-color: inherit;
+
+ align-self: flex-start;
+ margin: var(--wpds-dimension-padding-md);
+ padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
+ border-radius: var(--wpds-border-radius-md);
+ color: var(--wpds-color-fg-interactive-neutral-strong);
+ background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong) 25%, transparent);
+ backdrop-filter: blur(3px) brightness(0.55) saturate(0.25);
+ -webkit-backdrop-filter: blur(10px) brightness(0.55) saturate(0.65);
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
+}
+
+.with-image::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -2;
+ background-image: var(--on-this-day-bg-image);
+ background-position: center;
+ background-size: cover;
+ filter: var(--on-this-day-bg-filter, none);
+ transition: filter var(--wpds-motion-duration-lg) var(--wpds-motion-easing-expressive);
+}
+
+.with-image:hover::before,
+.with-image:focus-within::before {
+ filter: none;
+}
diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/index.ts b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts
new file mode 100644
index 00000000000000..267f539563a148
--- /dev/null
+++ b/widgets/on-this-day/hooks/use-on-this-day-post/index.ts
@@ -0,0 +1,7 @@
+export { default as useOnThisDayPost } from './use-on-this-day-post';
+export type {
+ OnThisDayPost,
+ UseOnThisDayPostOptions,
+ UseOnThisDayPostResult,
+ TimeRange,
+} from './use-on-this-day-post';
diff --git a/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts
new file mode 100644
index 00000000000000..b5bf013b96a221
--- /dev/null
+++ b/widgets/on-this-day/hooks/use-on-this-day-post/use-on-this-day-post.ts
@@ -0,0 +1,275 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Named time-range presets selectable via the widget's `timeRange`
+ * attribute. `custom` requires a companion `customDate` (`YYYY-MM-DD`).
+ */
+export type TimeRange =
+ | 'one-year-ago'
+ | 'five-years-ago'
+ | 'ten-years-ago'
+ | 'oldest-on-this-day'
+ | 'custom';
+
+/**
+ * Subset of the WordPress post REST shape consumed by the widget,
+ * normalized so the view does not need to know that the featured image
+ * URL comes from a separate `attachment` entity.
+ */
+export interface OnThisDayPost {
+ id: number;
+ date: string;
+ title: { rendered: string };
+ excerpt: { rendered: string };
+ link: string;
+ commentCount: number;
+ featuredImageUrl: string | null;
+}
+
+/**
+ * Raw post record returned by the posts collection. `featured_media` is
+ * the attachment id; the URL is resolved with a second `getEntityRecord`
+ * call against the `attachment` entity.
+ */
+interface RawPost {
+ id: number;
+ date: string;
+ title: { rendered: string };
+ excerpt: { rendered: string };
+ link: string;
+ featured_media?: number;
+ comment_count?: string | number;
+}
+
+/**
+ * Minimal subset of an `attachment` REST record consumed by the hook.
+ */
+interface AttachmentRecord {
+ id: number;
+ source_url?: string;
+}
+
+/**
+ * Return value of {@link useOnThisDayPost}.
+ */
+export interface UseOnThisDayPostResult {
+ /**
+ * The matching post resolved by the selected time range, or `null`
+ * when no post matches or the request has not resolved yet.
+ */
+ post: OnThisDayPost | null;
+
+ /**
+ * `true` until the underlying `getEntityRecords` resolver has settled.
+ */
+ isResolving: boolean;
+
+ /**
+ * Whether the user has at least one published post overall. Used by
+ * the surface to bifurcate the empty state between "write your first
+ * post" (no history) and "broaden the search" (history exists but
+ * nothing matches the current filter).
+ */
+ hasAnyPosts: boolean;
+}
+
+/**
+ * Hook options. All optional; defaults to the `one-year-ago` preset.
+ */
+export interface UseOnThisDayPostOptions {
+ timeRange?: TimeRange;
+ customDate?: string;
+}
+
+/**
+ * Formats a `Date` as the `MM-DD` string expected by the
+ * `on_this_day` REST parameter.
+ *
+ * @param date Date to format.
+ * @return Zero-padded month and day separated by a dash.
+ */
+export function toMonthDay( date: Date ): string {
+ const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
+ const day = String( date.getDate() ).padStart( 2, '0' );
+ return `${ month }-${ day }`;
+}
+
+/**
+ * Formats a `Date` as `YYYY-MM-DD` in the local calendar, suitable for
+ * building `after`/`before` REST parameters against a specific day.
+ *
+ * @param date Date to format.
+ * @return Year, month, and day joined with dashes.
+ */
+function toYearMonthDay( date: Date ): string {
+ const year = String( date.getFullYear() );
+ const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
+ const day = String( date.getDate() ).padStart( 2, '0' );
+ return `${ year }-${ month }-${ day }`;
+}
+
+/**
+ * Returns `now` shifted back by `years`. Calendar-safe via `setFullYear`
+ * (leap-day shifts to Feb 28 of the target year by JavaScript spec).
+ *
+ * @param now Reference date to shift back from.
+ * @param years Number of years to subtract.
+ * @return New `Date` shifted into the past.
+ */
+function shiftYears( now: Date, years: number ): Date {
+ const next = new Date( now );
+ next.setFullYear( next.getFullYear() - years );
+ return next;
+}
+
+/**
+ * Translates the selected time range into the REST query fragment that
+ * narrows the posts collection to the matching day(s).
+ *
+ * - `one-year-ago` / `five-years-ago` / `ten-years-ago`: a single
+ * calendar day in the past, queried with native `after` / `before`.
+ * - `oldest-on-this-day`: matches today's month and day across all
+ * years, using the `on_this_day` filter registered by the plugin.
+ * - `custom`: a single calendar day specified by `customDate` (`YYYY-MM-DD`).
+ *
+ * @param timeRange Selected preset.
+ * @param customDate Calendar day for the `custom` preset; ignored otherwise.
+ * @param now Reference date used to anchor the relative presets.
+ * @return REST query fragment, or `null` when the inputs do not resolve
+ * to a valid query (e.g. `custom` without a parseable date).
+ */
+export function buildTimeRangeQuery(
+ timeRange: TimeRange,
+ customDate: string | undefined,
+ now: Date
+): Record< string, string > | null {
+ if ( timeRange === 'oldest-on-this-day' ) {
+ return { on_this_day: toMonthDay( now ) };
+ }
+
+ let target: Date | null = null;
+
+ if ( timeRange === 'one-year-ago' ) {
+ target = shiftYears( now, 1 );
+ } else if ( timeRange === 'five-years-ago' ) {
+ target = shiftYears( now, 5 );
+ } else if ( timeRange === 'ten-years-ago' ) {
+ target = shiftYears( now, 10 );
+ } else if ( timeRange === 'custom' && customDate ) {
+ const parsed = new Date( `${ customDate }T00:00:00` );
+ if ( ! Number.isNaN( parsed.getTime() ) ) {
+ target = parsed;
+ }
+ }
+
+ if ( ! target ) {
+ return null;
+ }
+
+ const ymd = toYearMonthDay( target );
+ return {
+ after: `${ ymd }T00:00:00`,
+ before: `${ ymd }T23:59:59`,
+ };
+}
+
+/**
+ * Reads the post that matches the selected time range, then resolves
+ * its featured image via the `attachment` entity.
+ *
+ * Calls the native posts collection with either the `on_this_day=MM-DD`
+ * parameter (for the cross-year preset) or `after`/`before` (for a
+ * specific day). When the post has a non-zero `featured_media`, a
+ * second core-data selector fetches the attachment record; the URL is
+ * flattened into `featuredImageUrl` for the view. Both requests are
+ * cached and deduplicated by core-data.
+ *
+ * @param options Hook options.
+ * @param options.timeRange Preset that drives which post the hook surfaces.
+ * Defaults to `oldest-on-this-day`.
+ * @param options.customDate Calendar day (`YYYY-MM-DD`) used when
+ * `timeRange` is `custom`.
+ * @return The matching post and a resolution flag.
+ */
+export default function useOnThisDayPost( {
+ timeRange = 'oldest-on-this-day',
+ customDate,
+}: UseOnThisDayPostOptions = {} ): UseOnThisDayPostResult {
+ return useSelect(
+ ( select ) => {
+ const { getEntityRecords, getEntityRecord } = select( coreStore );
+
+ const anyPostsProbe = getEntityRecords( 'postType', 'post', {
+ per_page: 1,
+ status: 'publish',
+ _fields: 'id',
+ } ) as Array< { id: number } > | null;
+ const probeIsResolving = anyPostsProbe === null;
+ const hasAnyPosts =
+ anyPostsProbe !== null && anyPostsProbe.length > 0;
+
+ const dateQuery = buildTimeRangeQuery(
+ timeRange,
+ customDate,
+ new Date()
+ );
+
+ if ( ! dateQuery ) {
+ return {
+ post: null,
+ isResolving: probeIsResolving,
+ hasAnyPosts,
+ };
+ }
+
+ const records = getEntityRecords( 'postType', 'post', {
+ ...dateQuery,
+ per_page: 1,
+ orderby: 'date',
+ order: 'asc',
+ _fields:
+ 'id,date,title,excerpt,link,featured_media,comment_count',
+ } ) as RawPost[] | null;
+
+ if ( records === null ) {
+ return { post: null, isResolving: true, hasAnyPosts };
+ }
+
+ const raw = records[ 0 ];
+
+ if ( ! raw ) {
+ return {
+ post: null,
+ isResolving: probeIsResolving,
+ hasAnyPosts,
+ };
+ }
+
+ const mediaId = raw.featured_media ?? 0;
+ const media = mediaId
+ ? ( getEntityRecord( 'postType', 'attachment', mediaId, {
+ context: 'view',
+ } ) as AttachmentRecord | undefined | null )
+ : null;
+
+ return {
+ post: {
+ id: raw.id,
+ date: raw.date,
+ title: raw.title,
+ excerpt: raw.excerpt,
+ link: raw.link,
+ commentCount: Number( raw.comment_count ?? 0 ),
+ featuredImageUrl: media?.source_url ?? null,
+ },
+ isResolving: false,
+ hasAnyPosts,
+ };
+ },
+ [ timeRange, customDate ]
+ );
+}
diff --git a/widgets/on-this-day/render.tsx b/widgets/on-this-day/render.tsx
new file mode 100644
index 00000000000000..4c598d0eded213
--- /dev/null
+++ b/widgets/on-this-day/render.tsx
@@ -0,0 +1,36 @@
+/**
+ * Internal dependencies
+ */
+import type { WidgetRenderProps } from '../../routes/dashboard/widget-types';
+import { useOnThisDayPost } from './hooks/use-on-this-day-post';
+import type { TimeRange } from './hooks/use-on-this-day-post';
+import {
+ OnThisDayView,
+ type BackgroundEffect,
+} from './components/on-this-day-view';
+
+interface OnThisDayAttributes {
+ timeRange?: TimeRange;
+ customDate?: string;
+ effect?: BackgroundEffect;
+}
+
+/**
+ * Renders a published post resolved by the selected time range. The
+ * surrounding surface owns the chrome (header, footer, error boundary);
+ * this component emits only the widget body, delegating layout to
+ * {@link OnThisDayView}.
+ *
+ * @param props Component props.
+ * @param props.attributes Widget attributes set on this instance.
+ */
+export default function OnThisDay( {
+ attributes,
+}: WidgetRenderProps< OnThisDayAttributes > ) {
+ const data = useOnThisDayPost( {
+ timeRange: attributes.timeRange,
+ customDate: attributes.customDate,
+ } );
+
+ return ;
+}
diff --git a/widgets/on-this-day/widget.json b/widgets/on-this-day/widget.json
new file mode 100644
index 00000000000000..17b27f039b29d1
--- /dev/null
+++ b/widgets/on-this-day/widget.json
@@ -0,0 +1,7 @@
+{
+ "name": "core/on-this-day",
+ "title": "On This Day",
+ "description": "The oldest post published on this day in history.",
+ "category": "dashboard",
+ "presentation": "full-bleed"
+}
diff --git a/widgets/on-this-day/widget.ts b/widgets/on-this-day/widget.ts
new file mode 100644
index 00000000000000..02ba8805c76c15
--- /dev/null
+++ b/widgets/on-this-day/widget.ts
@@ -0,0 +1,79 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { calendar } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { WidgetTypeMetadata } from '../../routes/dashboard/widget-types';
+import type { BackgroundEffect } from './components/on-this-day-view';
+import type { TimeRange } from './hooks/use-on-this-day-post';
+
+/**
+ * Shape of the widget's persisted attributes. Bound to
+ * `WidgetTypeMetadata` below so the attribute schema and the example
+ * defaults are validated against this type.
+ */
+type OnThisDayWidgetType = {
+ timeRange?: TimeRange;
+ customDate?: string;
+ effect?: BackgroundEffect;
+};
+
+const widget: WidgetTypeMetadata< OnThisDayWidgetType > = {
+ apiVersion: 1,
+ name: 'core/on-this-day',
+ title: __( 'On This Day' ),
+ description: __( 'A post published on this day in history.' ),
+ icon: calendar,
+ keywords: [
+ __( 'memory' ),
+ __( 'archive' ),
+ __( 'anniversary' ),
+ __( 'history' ),
+ ],
+ attributes: [
+ {
+ id: 'timeRange',
+ label: __( 'Time range' ),
+ type: 'text',
+ elements: [
+ { value: 'one-year-ago', label: __( 'One year ago' ) },
+ { value: 'five-years-ago', label: __( 'Five years ago' ) },
+ { value: 'ten-years-ago', label: __( 'Ten years ago' ) },
+ {
+ value: 'oldest-on-this-day',
+ label: __( 'Oldest on this day (any year)' ),
+ },
+ { value: 'custom', label: __( 'Custom date' ) },
+ ],
+ },
+ {
+ id: 'customDate',
+ label: __( 'Custom date (YYYY-MM-DD)' ),
+ type: 'text',
+ },
+ {
+ id: 'effect',
+ label: __( 'Background effect' ),
+ type: 'text',
+ elements: [
+ { value: 'vintage', label: __( 'Vintage memory' ) },
+ { value: 'dim-saturate', label: __( 'Dim + saturate' ) },
+ { value: 'dim-blur', label: __( 'Dim + blur' ) },
+ { value: 'grayscale', label: __( 'Grayscale' ) },
+ { value: 'none', label: __( 'No filter' ) },
+ ],
+ },
+ ],
+ example: {
+ attributes: {
+ timeRange: 'oldest-on-this-day',
+ effect: 'vintage',
+ },
+ },
+};
+
+export default widget;
diff --git a/widgets/package.json b/widgets/package.json
new file mode 100644
index 00000000000000..7e81f1b8ddbdf4
--- /dev/null
+++ b/widgets/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@wordpress/widgets-app",
+ "version": "0.0.0",
+ "description": "Application widgets surfaced by the Gutenberg editor.",
+ "private": true,
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "widgets"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/widgets",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/WordPress/gutenberg.git",
+ "directory": "widgets"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "dependencies": {
+ "@wordpress/core-data": "file:../packages/core-data",
+ "@wordpress/data": "file:../packages/data",
+ "@wordpress/dataviews": "file:../packages/dataviews",
+ "@wordpress/date": "file:../packages/date",
+ "@wordpress/element": "file:../packages/element",
+ "@wordpress/html-entities": "file:../packages/html-entities",
+ "@wordpress/i18n": "file:../packages/i18n",
+ "@wordpress/icons": "file:../packages/icons",
+ "@wordpress/ui": "file:../packages/ui",
+ "@wordpress/url": "file:../packages/url",
+ "clsx": "^2.1.1"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/widgets/tsconfig.json b/widgets/tsconfig.json
new file mode 100644
index 00000000000000..7882b230a3ba63
--- /dev/null
+++ b/widgets/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": false,
+ "noEmit": true,
+ "emitDeclarationOnly": false,
+ "rootDir": ".",
+ "types": [ "style-imports", "react-css-custom-properties" ]
+ },
+ "references": [
+ { "path": "../packages/core-data" },
+ { "path": "../packages/data" },
+ { "path": "../packages/dataviews" },
+ { "path": "../packages/date" },
+ { "path": "../packages/element" },
+ { "path": "../packages/html-entities" },
+ { "path": "../packages/i18n" },
+ { "path": "../packages/icons" },
+ { "path": "../packages/ui" },
+ { "path": "../packages/url" },
+ { "path": "../routes/dashboard/widget-types" }
+ ],
+ "include": [ "**/*.ts", "**/*.tsx" ],
+ "exclude": [ "**/test/**", "**/build/**", "**/build-types/**" ]
+}