Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Date range control: name a custom range covering a whole month or year by that period, and spell the dates out in the button's tooltip.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
formatDateRange,
formatDateRangeCompact,
formatDateRangeMinimal,
formatDateRangeNatural,
} from '../format-date-range';

// The en dash between thin spaces that CLDR puts between the ends of a range.
Expand Down Expand Up @@ -363,3 +364,87 @@ describe( 'formatDateRangeMinimal', () => {
} );
} );
} );

describe( 'formatDateRangeNatural', () => {
beforeAll( () => jest.useFakeTimers().setSystemTime( utcDate( 2026, 8, 13 ) ) );
afterAll( () => jest.useRealTimers() );

describe( 'en_US site', () => {
beforeEach( () => setSettings( EN_US_SETTINGS ) );

it( 'names a whole calendar month', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2025, 3, 1 ), to: utcDate( 2025, 3, 31 ) } )
).toBe( 'March 2025' );
} );

// The label is alone on the control, so the year is not redundant the way
// it is in a range with both ends on screen.
it( 'keeps the year on a whole month of the current year', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2026, 3, 1 ), to: utcDate( 2026, 3, 31 ) } )
).toBe( 'March 2026' );
} );

it( 'names a whole calendar year', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2025, 1, 1 ), to: utcDate( 2025, 12, 31 ) } )
).toBe( '2025' );
} );

it( 'measures the month by its own length', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2024, 2, 1 ), to: utcDate( 2024, 2, 29 ) } )
).toBe( 'February 2024' );
} );

it( 'names the month whatever time of day the range ends at', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2025, 3, 1 ), to: utcDate( 2025, 3, 31, 23 ) } )
).toBe( 'March 2025' );
} );

it( 'falls back on a range that stops short of the month', () => {
const range = { from: utcDate( 2026, 3, 1 ), to: utcDate( 2026, 3, 30 ) };

expect( formatDateRangeNatural( range ) ).toBe( formatDateRangeMinimal( range ) );
} );

it( 'falls back on February shortened to 28 days in a leap year', () => {
const range = { from: utcDate( 2024, 2, 1 ), to: utcDate( 2024, 2, 28 ) };

expect( formatDateRangeNatural( range ) ).toBe( formatDateRangeMinimal( range ) );
} );

// Two whole months name no single period, and the design asks for the
// shortest form rather than a made-up one.
it( 'falls back across two whole months', () => {
const range = { from: utcDate( 2026, 3, 1 ), to: utcDate( 2026, 4, 30 ) };

expect( formatDateRangeNatural( range ) ).toBe( formatDateRangeMinimal( range ) );
} );

it( 'falls back on a month-long window that sits between two months', () => {
const range = { from: utcDate( 2026, 3, 15 ), to: utcDate( 2026, 4, 14 ) };

expect( formatDateRangeNatural( range ) ).toBe( formatDateRangeMinimal( range ) );
} );
} );

describe( 'es_ES site', () => {
beforeEach( () => setSettings( ES_ES_SETTINGS ) );

it( 'names a whole month the way the site orders its dates', () => {
expect(
formatDateRangeNatural( { from: utcDate( 2025, 3, 1 ), to: utcDate( 2025, 3, 31 ) } )
).toBe( 'marzo de 2025' );
} );
} );

it( 'returns an empty string when an end is missing', () => {
setSettings( EN_US_SETTINGS );

expect( formatDateRangeNatural( { from: utcDate( 2025, 3, 1 ), to: undefined } ) ).toBe( '' );
expect( formatDateRangeNatural() ).toBe( '' );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ describe( 'formatDate', () => {
expect( formatDate( JUNE_21, 'short' ) ).toBe( 'June 21' );
} );

it( 'formats "monthYear" as the site format without its day', () => {
expect( formatDate( JUNE_21, 'monthYear' ) ).toBe( 'June 2025' );
} );

it( 'formats "year"', () => {
expect( formatDate( JUNE_21, 'year' ) ).toBe( '2025' );
} );
Expand Down Expand Up @@ -59,6 +63,10 @@ describe( 'formatDate', () => {
expect( formatDate( JUNE_21, 'short' ) ).toBe( '21 de junio' );
} );

it( 'drops the leading "<day> de" for "monthYear"', () => {
expect( formatDate( JUNE_21, 'monthYear' ) ).toBe( 'junio de 2025' );
} );

it( 'keeps "iso" untranslated so it stays machine-readable', () => {
expect( formatDate( JUNE_21, 'iso' ) ).toBe( '2025-06-21' );
} );
Expand All @@ -74,6 +82,12 @@ describe( 'formatDate', () => {
expect( formatDate( JUNE_21, 'short' ) ).toBe( '2025' );
} );

it( 'falls back to the site format when removing the day leaves nothing', () => {
setSettings( settingsFor( 'day-only-test', 'j' ) );

expect( formatDate( JUNE_21, 'monthYear' ) ).toBe( '21' );
} );

it( 'does not add a second weekday when the site format already names one', () => {
setSettings( settingsFor( 'weekday-format-test', 'l, F j, Y' ) );

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Internal dependencies
*/
import { withShortMonth, withWeekday, withoutYear } from '../php-format';
import { withShortMonth, withWeekday, withoutDay, withoutYear } from '../php-format';

describe( 'withoutYear', () => {
// Real `date_format` defaults taken from the WordPress core language packs,
Expand Down Expand Up @@ -115,3 +115,54 @@ describe( 'withWeekday', () => {
expect( withWeekday( '\\l\\a j F' ) ).toBe( 'l, \\l\\a j F' );
} );
} );

describe( 'withoutDay', () => {
it( 'drops a day and the separator that follows it', () => {
expect( withoutDay( 'F j, Y' ) ).toBe( 'F Y' );
} );

it( 'drops a trailing day and the separator that introduces it', () => {
expect( withoutDay( 'Y-m-d' ) ).toBe( 'Y-m' );
} );

it( 'removes the day from an all-numeric format', () => {
expect( withoutDay( 'd/m/Y' ) ).toBe( 'm/Y' );
} );

it( 'drops the words a leading day introduces', () => {
// es_ES. The first "de" belongs to the day, the second to the year.
expect( withoutDay( 'j \\d\\e F \\d\\e Y' ) ).toBe( 'F \\d\\e Y' );
} );

it( 'drops the ordinal suffix along with the day it trails', () => {
expect( withoutDay( 'jS F Y' ) ).toBe( 'F Y' );
} );

it( 'drops the ordinal dot of a day-first numeric format', () => {
// Finnish and Czech. The dot marks the day as ordinal, so it goes too.
expect( withoutDay( 'j.n.Y' ) ).toBe( 'n.Y' );
expect( withoutDay( 'j. n. Y' ) ).toBe( 'n. Y' );
} );

it( 'drops a prefix bound to the month by the day it needed', () => {
// he_IL. "בF" reads "in March", which only a dated form asks for.
expect( withoutDay( 'j בF Y' ) ).toBe( 'F Y' );
} );

it( 'drops the weekday, which names no month either', () => {
expect( withoutDay( 'l, F j, Y' ) ).toBe( 'F Y' );
expect( withoutDay( 'D, d M Y' ) ).toBe( 'M Y' );
} );

it( 'leaves an escaped day character alone', () => {
expect( withoutDay( '\\j F Y' ) ).toBe( '\\j F Y' );
} );

it( 'returns the format unchanged when it carries no day', () => {
expect( withoutDay( 'F Y' ) ).toBe( 'F Y' );
} );

it( 'returns an empty string when the format is only a day', () => {
expect( withoutDay( 'j' ) ).toBe( '' );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,97 @@ export const formatDateRangeMinimal = ( range?: DateRange ): string => {

return formatRange( isCurrentYear ? 'compactNoYear' : 'compact', range );
};

/** A site-local calendar date, as the parts a boundary check compares. */
type CalendarDate = {
year: number;
month: number;
day: number;
};

/**
* Read a date as its site-local calendar parts.
*
* Through the ISO form, since the `Date` getters answer in the browser's
* timezone and would name a different day for anyone offset from the site.
*
* @param date - The instant to read.
* @return Its year, 1-based month, and day of the month.
*/
function toCalendarDate( date: Date ): CalendarDate {
const [ year, month, day ] = formatDate( date, 'iso' ).split( '-' ).map( Number );

return { year, month, day };
}

/**
* The length of a calendar month.
*
* @param date - Any date in the month.
* @return Its number of days.
*/
function getMonthLength( date: CalendarDate ): number {
// Day zero of the next month is the last day of this one.
return new Date( Date.UTC( date.year, date.month, 0 ) ).getUTCDate();
}

/**
* Whether the range covers one calendar month, first day to last.
*
* @param from - The range's start, in calendar parts.
* @param to - The range's end, in calendar parts.
* @return Whether the range is that month.
*/
function isWholeMonth( from: CalendarDate, to: CalendarDate ): boolean {
return (
from.year === to.year &&
from.month === to.month &&
from.day === 1 &&
to.day === getMonthLength( to )
);
}

/**
* Whether the range covers one calendar year, January 1 to December 31.
*
* @param from - The range's start, in calendar parts.
* @param to - The range's end, in calendar parts.
* @return Whether the range is that year.
*/
function isWholeYear( from: CalendarDate, to: CalendarDate ): boolean {
return (
from.year === to.year && from.month === 1 && from.day === 1 && to.month === 12 && to.day === 31
);
}

/**
* Format a date range as the period it names, where it names one.
*
* A range covering exactly one calendar month or year reads as that month or
* year, and anything else falls back to `formatDateRangeMinimal`. Both forms
* keep the year, the current one included: this label is the only thing naming
* the period on the control it sits on.
*
* @param range - The range to format.
* @return The formatted range, or `''` when `range`, `from`, or `to` is missing.
*/
export const formatDateRangeNatural = ( range?: DateRange ): string => {
const { from, to } = range ?? {};

if ( ! from || ! to ) {
return '';
}

const start = toCalendarDate( from );
const end = toCalendarDate( to );

if ( isWholeYear( start, end ) ) {
return formatDate( from, 'year' );
}

if ( isWholeMonth( start, end ) ) {
return formatDate( from, 'monthYear' );
}

return formatDateRangeMinimal( range );
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { dateI18n, getSettings } from '@wordpress/date';
* Internal dependencies
*/
import { intlLocale } from './elide-range';
import { hasToken, withShortMonth, withWeekday, withoutYear } from './php-format';
import { hasToken, withShortMonth, withWeekday, withoutDay, withoutYear } from './php-format';

/** Fixed because this format backs form values and query parameters. */
const ISO_FORMAT = 'Y-m-d';
Expand All @@ -19,6 +19,7 @@ export type DateFormatName =
| 'compact'
| 'compactNoYear'
| 'short'
| 'monthYear'
| 'year'
| 'iso'
| 'full'
Expand Down Expand Up @@ -67,6 +68,10 @@ function formatFor( name: DateFormatName ): string {
return withoutYearFormat;
}

if ( name === 'monthYear' ) {
return withoutDay( siteFormat ) || siteFormat;
}

if ( name === 'full' ) {
return withWeekday( siteFormat );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export {
formatDateRange,
formatDateRangeCompact,
formatDateRangeMinimal,
formatDateRangeNatural,
} from './format-date-range';
export { formatDateRangeLong } from './format-date-range-long';
Loading
Loading