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..97377baf3db34 --- /dev/null +++ b/src/wp-admin/css/on-this-day.css @@ -0,0 +1,385 @@ +/* ============================================================================= + On This Day dashboard widget + ============================================================================= + Organised as: + 1. Design tokens (custom properties on the widget root) + 2. Postbox chrome (#dashboard_on_this_day) + 3. Widget title date pill (`.on-this-day-title::after`) + 4. Timeline (year headers, vertical line, post rows) + 5. Post row (icon circle, title, excerpt, meta row) + 6. Window control + 7. Empty state + 8. Adaptive rules (reduced motion, small viewports) + ============================================================================= */ + +/* ----------------------------------------------------------------------------- + 1. Design tokens + + Scoped to the postbox so the title spans (rendered in `.hndle`, outside the + `.on-this-day-widget` wrapper) can reference the same variables as the body. + + Accent: `--wp-admin-theme-color*` are the only color custom properties core + exposes at runtime (see src/wp-admin/css/colors/_tokens.scss), so the widget + follows the user's selected admin color scheme (Blue, Modern, Coffee, etc.). + Fallback values match the classic "Fresh" scheme. + ----------------------------------------------------------------------------- */ +#dashboard_on_this_day { + /* Accent — theme-color aware, follows the user's admin color scheme. */ + --otd-accent: var(--wp-admin-theme-color, #2271b1); + --otd-accent-dark: var(--wp-admin-theme-color-darker-10, #135e96); + --otd-accent-rgb: var(--wp-admin-theme-color--rgb, 34, 113, 177); + --otd-accent-8: rgba(var(--otd-accent-rgb), 0.08); + --otd-accent-15: rgba(var(--otd-accent-rgb), 0.15); + + /* Neutrals — classic wp-admin palette. */ + --otd-ink: #1d2327; + --otd-text: #2c3338; + --otd-muted: #646970; + --otd-subtle: #8c8f94; + --otd-line: #dcdcde; + + /* Semantic (theme-independent). */ + --otd-private: #b32d2e; + + /* Shape + motion. */ + --otd-pill: 9999px; + --otd-ease: 0.15s ease; + + /* ------------------------------------------------------------------------- + 2. Postbox chrome + ------------------------------------------------------------------------- */ + + /* To honour the postbox border-radius. */ + overflow: hidden; + + &:not(.closed) .inside { + margin: 0; + padding: 0; + max-height: 560px; + display: flex; + flex-direction: column; + overflow: hidden; + } + + & .hndle { + gap: 0; + } + + & .on-this-day-widget { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + font-size: 13px; + color: var(--otd-ink); + line-height: 1.5; + } + + /* Scrollable content area — keeps the timeline / empty state contained + so the window control below pins to the bottom of the postbox body. */ + & .on-this-day-scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + } + + /* ------------------------------------------------------------------------- + 3. Widget title date pill + + The widget is registered with a plain-text title ("On This Day") so that + Screen Options and box-order preferences stay clean. The current date + window label is rendered from `data-otd-window-label` on the title span. + ------------------------------------------------------------------------- */ + & .on-this-day-title { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + &::after { + content: attr(data-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(--otd-accent-dark); + background: var(--otd-accent-8); + border-radius: var(--otd-pill); + white-space: nowrap; + vertical-align: 1px; + } + } + + /* ------------------------------------------------------------------------- + 4. Timeline + ------------------------------------------------------------------------- + Layout math (reused below): + padding-left of .on-this-day-timeline = 20px + icon width = 28px -> centers at 20 + 14 = 34px + gap between icon and body = 14px -> body column starts at 20 + 28 + 14 = 62px + ------------------------------------------------------------------------- */ + & .on-this-day-timeline { + margin: 0; + padding: 12px 20px 16px; + list-style: none; + } + + & .on-this-day-year-group { + list-style: none; + margin: 0; + padding: 0; + } + + & .on-this-day-year-header { + margin: 8px 0 8px; + font-size: 15px; + font-weight: 500; + line-height: 1.4; + color: var(--otd-subtle); + } + + & .on-this-day-year-number { + color: var(--otd-muted); + } + + & .on-this-day-year-ago { + margin-left: 6px; + color: var(--otd-subtle); + } + + & .on-this-day-post-list { + position: relative; + margin: 0; + padding: 0; + list-style: none; + + /* Vertical guide line, scoped per year group so the year header + naturally interrupts the line between groups. The halo on each + icon circle (see below) punches ~3px gaps around every circle. */ + &::before { + content: ""; + position: absolute; + left: 14px; /* center of the 28px icon column */ + top: 6px; + bottom: 14px; + width: 1px; + background: var(--otd-line); + } + } + + /* ------------------------------------------------------------------------- + 5. Post row + ------------------------------------------------------------------------- */ + & .on-this-day-post { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + padding: 6px 0 14px; + + /* Icon circle on the left. */ + & .on-this-day-post-icon { + position: relative; + z-index: 1; /* sits above the vertical line */ + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #fff; + border: 1px solid var(--otd-line); + color: var(--otd-muted); + + /* 3px white halo so the timeline line appears to stop short of + the circle on both sides rather than butting right into it. */ + box-shadow: 0 0 0 3px #fff; + + & svg { + display: block; + } + } + + /* Featured-image variant: the thumbnail fills the circle. + Border is hidden so the image reads as the "chip" itself, + while the 3px white halo still separates it from the + timeline line behind it. */ + & .on-this-day-post-icon.has-thumbnail { + overflow: hidden; + border-color: transparent; + background: var(--otd-line); /* shown briefly before the image loads */ + + & img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &.is-private .on-this-day-post-icon { + color: var(--otd-private); + border-color: #f5c9cc; + background: #fcf0f1; + } + } + + & .on-this-day-post-body { + min-width: 0; + } + + & .on-this-day-post-title { + margin: 0 0 2px; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + + & a { + color: var(--otd-ink); + text-decoration: none; + box-shadow: none; + + &:hover, + &:focus { + color: var(--otd-accent); + } + } + } + + & .on-this-day-post-excerpt { + margin: 0 0 6px; + color: var(--otd-text); + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } + + & .on-this-day-post-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: var(--otd-subtle); + line-height: 1.5; + } + + & .on-this-day-post-time { + color: var(--otd-muted); + } + + & .on-this-day-post-sep { + color: var(--otd-line); + } + + & .on-this-day-post-categories { + max-width: 240px; + color: var(--otd-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .on-this-day-post-private { + color: var(--otd-private); + font-weight: 500; + } + + /* Actions row sits on its own line below the meta, left-aligned + with the post body column. Uses core `.button` styling so it + picks up the user's admin color scheme automatically. */ + & .on-this-day-post-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + align-items: center; + } + + /* ------------------------------------------------------------------------- + 6. Window control + ------------------------------------------------------------------------- */ + & .on-this-day-window-form { + flex: 0 0 auto; + padding: 12px 20px 14px; + border-top: 1px solid var(--otd-line); + background: #fff; + } + + & .on-this-day-window-control { + display: grid; + grid-template-columns: auto minmax(120px, 1fr) auto; + align-items: center; + gap: 8px; + + & input[type="range"] { + width: 100%; + margin: 0; + accent-color: var(--otd-accent); + } + } + + & .on-this-day-window-scale { + color: var(--otd-subtle); + font-size: 11px; + line-height: 1.4; + white-space: nowrap; + } + + /* ------------------------------------------------------------------------- + 7. Empty state + ------------------------------------------------------------------------- */ + & .on-this-day-empty { + text-align: center; + padding: 28px 20px 24px; + } + + & .on-this-day-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin-bottom: 10px; + color: var(--otd-accent); + background: var(--otd-accent-8); + border-radius: 50%; + box-shadow: inset 0 0 0 1px var(--otd-accent-15); + } + + & .on-this-day-empty-title { + margin: 6px 0 4px; + font-size: 15px; + font-weight: 600; + color: var(--otd-ink); + } + + & .on-this-day-empty-text { + max-width: 340px; + margin: 0 auto 12px; + color: var(--otd-text); + line-height: 1.55; + } + + & .on-this-day-empty-cta { + margin: 0; + } +} + +/* ----------------------------------------------------------------------------- + 8. Adaptive rules + ----------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + #dashboard_on_this_day { + & .on-this-day-post-title a, + & .on-this-day-post-action { + transition: none; + } + } +} + diff --git a/src/wp-admin/includes/class-wp-on-this-day.php b/src/wp-admin/includes/class-wp-on-this-day.php new file mode 100644 index 0000000000000..917ef2a852591 --- /dev/null +++ b/src/wp-admin/includes/class-wp-on-this-day.php @@ -0,0 +1,625 @@ +%s', + esc_attr( self::get_window_label( self::get_window_days() ) ), + __( 'On This Day' ) + ), + array( __CLASS__, 'render_dashboard_widget' ) + ); + } + + /** + * Renders the dashboard widget output. + * + * The rendered HTML is cached per user, locale, and site date. The + * cache key also incorporates 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 `time_format` option changes. + * They do not bust the cache; stale time 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(); + $window_days = self::get_window_days( $user_id ); + + $cache_key = sprintf( + 'render:v%d:%d:%d:%s:%s:%s', + self::CACHE_VERSION, + $user_id, + $window_days, + determine_locale(), + current_time( 'Y-m-d' ), + wp_cache_get_last_changed( 'posts' ) + ); + + $cached = wp_cache_get( $cache_key, self::CACHE_GROUP ); + if ( ! is_string( $cached ) ) { + $posts = self::get_posts( $user_id, $window_days ); + + ob_start(); + if ( empty( $posts ) ) { + self::render_empty_state( $window_days ); + } else { + self::render_posts( $posts, $window_days ); + } + $cached = ob_get_clean(); + + wp_cache_set( $cache_key, $cached, self::CACHE_GROUP, DAY_IN_SECONDS ); + } + + echo '
'; + } + + /** + * Retrieves posts by a given author that were published in the + * selected date window in previous years. + * + * The "selected date window, prior year" constraint is expressed as a + * `date_query`: clauses pinning each `month`/`day`, combined with a + * `before` clause anchored to January 1 of the current year. + * + * @since 7.1.0 + * + * @param int $user_id Author ID to query posts for. + * @param int $window_days Number of days to include, starting with today. + * @return WP_Post[] Array of posts ordered by newest first. + */ + public static function get_posts( $user_id, $window_days = self::DEFAULT_WINDOW_DAYS ) { + $window_days = self::clamp_window_days( $window_days ); + $year = (int) current_time( 'Y' ); + $date_query = array( + 'relation' => 'AND', + array( + 'before' => array( 'year' => $year ), + ), + array_merge( + array( 'relation' => 'OR' ), + self::get_window_date_query_clauses( $window_days ) + ), + ); + + $args = array( + 'author' => (int) $user_id, + 'post_type' => 'post', + 'post_status' => array( 'publish', 'private' ), + 'posts_per_page' => self::POSTS_PER_PAGE, + 'ignore_sticky_posts' => true, + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + '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 The author ID the query is scoped to. + * @param int $window_days Number of days included in the date window. + */ + $args = apply_filters( 'dashboard_on_this_day_query_args', $args, $user_id, $window_days ); + + $query = new WP_Query( $args ); + + return $query->posts; + } + + /** + * Handles date window preference form submissions. + * + * @since 7.1.0 + */ + public static function handle_window_days_submission() { + if ( + 'POST' !== $_SERVER['REQUEST_METHOD'] || + ! isset( $_POST['action'] ) || + 'set_on_this_day_window' !== sanitize_text_field( wp_unslash( $_POST['action'] ) ) + ) { + return; + } + + check_admin_referer( 'set-on-this-day-window' ); + + $window_days = isset( $_POST['on_this_day_window_days'] ) ? wp_unslash( $_POST['on_this_day_window_days'] ) : self::DEFAULT_WINDOW_DAYS; + $window_days = self::clamp_window_days( $window_days ); + + update_user_meta( get_current_user_id(), self::WINDOW_DAYS_META_KEY, $window_days ); + + wp_safe_redirect( + add_query_arg( + 'on-this-day-window-updated', + '1', + admin_url( 'index.php' ) + ) + ); + exit; + } + + /** + * Renders the success admin notice after the date window preference is saved. + * + * Hooked to the `admin_notices` action and only outputs when the + * `on-this-day-window-updated` query argument is present. + * + * @since 7.1.0 + */ + public static function render_window_updated_notice() { + if ( ! isset( $_GET['on-this-day-window-updated'] ) ) { + return; + } + + $window_days = self::get_window_days(); + + wp_admin_notice( + sprintf( + _n( + 'On This Day duration updated to %d day.', + 'On This Day duration updated to %d days.', + $window_days + ), + number_format_i18n( $window_days ) + ), + array( + 'id' => 'otd-message', + 'type' => 'success', + 'dismissible' => true, + ) + ); + } + + /** + * Retrieves the current user's date window preference. + * + * @since 7.1.0 + * + * @param int $user_id User ID. + * @return int Number of days to include, between 1 and 7. + */ + public static function get_window_days( $user_id = 0 ) { + if ( ! $user_id ) { + $user_id = get_current_user_id(); + } + + $window_days = get_user_meta( $user_id, self::WINDOW_DAYS_META_KEY, true ); + + return self::clamp_window_days( $window_days ); + } + + /** + * Returns a human-readable label for the active date window. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + * @return string Date or date range label. + */ + public static function get_window_label( $window_days ) { + $window_days = self::clamp_window_days( $window_days ); + $start = current_datetime(); + $start_label = wp_date( 'F j', $start->getTimestamp(), $start->getTimezone() ); + + if ( self::MIN_WINDOW_DAYS === $window_days ) { + return $start_label; + } + + $end = $start->modify( '+' . ( $window_days - 1 ) . ' days' ); + $end_label = wp_date( 'F j', $end->getTimestamp(), $end->getTimezone() ); + + return sprintf( + /* translators: 1: Start date, 2: End date. */ + __( '%1$s - %2$s' ), + $start_label, + $end_label + ); + } + + /** + * Clamps the date window size to the supported range. + * + * @since 7.1.0 + * + * @param mixed $window_days Raw window size. + * @return int Number of days to include, between 1 and 7. + */ + protected static function clamp_window_days( $window_days ) { + return min( + max( (int) $window_days, self::MIN_WINDOW_DAYS ), + self::MAX_WINDOW_DAYS + ); + } + + /** + * Builds date query clauses for each day in the active window. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + * @return array[] Date query clauses. + */ + protected static function get_window_date_query_clauses( $window_days ) { + $date = current_datetime(); + $clauses = array(); + + for ( $offset = 0; $offset < $window_days; $offset++ ) { + $day_date = $date->modify( '+' . $offset . ' days' ); + $clauses[] = array( + 'month' => (int) $day_date->format( 'n' ), + 'day' => (int) $day_date->format( 'j' ), + ); + } + + return $clauses; + } + + /** + * Renders the empty state shown when no matching posts exist. + * + * @since 7.1.0 + * + * @param int $window_days Number of days included in the date window. + */ + protected static function render_empty_state( $window_days ) { + $window_days = self::clamp_window_days( $window_days ); + $start = current_datetime(); + $start_date = wp_date( 'Y-m-d', $start->getTimestamp(), $start->getTimezone() ); + $start_label = wp_date( 'F j', $start->getTimestamp(), $start->getTimezone() ); + ?> ++ %2$s', + esc_attr( $start_date ), + esc_html( $start_label ) + ); + + if ( self::MIN_WINDOW_DAYS === $window_days ) { + printf( + /* translators: %s: Current date, e.g. "April 22". */ + esc_html__( 'You haven\'t published anything on %s in previous years. Write something today and check back next year!' ), + '' . $start_time . '' + ); + } else { + $end = $start->modify( '+' . ( $window_days - 1 ) . ' days' ); + $end_time = sprintf( + '', + esc_attr( wp_date( 'Y-m-d', $end->getTimestamp(), $end->getTimezone() ) ), + esc_html( wp_date( 'F j', $end->getTimestamp(), $end->getTimezone() ) ) + ); + + printf( + /* translators: 1: Start date, 2: End date. */ + esc_html__( 'You haven\'t published anything between %1$s and %2$s in previous years. Write something today and check back next year!' ), + '' . $start_time . '', + '' . $end_time . '' + ); + } + ?> +
++ + + +
++ + + + +
+