Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4076f90
add SectionHeader component to ui package
retrofox Jul 28, 2026
e2cb2de
add SectionHeader stories
retrofox Jul 28, 2026
261e4c3
update DashboardSections props docblock
retrofox Jul 28, 2026
9776382
make SectionHeader subtitle optional
retrofox Jul 29, 2026
75b9406
render SectionHeader in dashboard sections
retrofox Jul 29, 2026
d247644
add changelog entry for the section header
retrofox Jul 29, 2026
d4d6ce2
Merge remote-tracking branch 'origin/trunk' into add/pa-section-header
retrofox Jul 29, 2026
8c3c0cb
clean jsdoc comments
retrofox Jul 29, 2026
0c464bd
Premium Analytics: measure and spell out a date range
retrofox Jul 29, 2026
af53eae
Premium Analytics: expose the applied comparison preset
retrofox Jul 29, 2026
ce694e0
Premium Analytics: compose the section header subtitle
retrofox Jul 29, 2026
3d569c2
Premium Analytics: show the date configuration on the dashboard
retrofox Jul 29, 2026
29f4873
Premium Analytics: name a day-long window with one date
retrofox Jul 29, 2026
bc59fa1
adjust section subtitle font size
retrofox Jul 29, 2026
b9dae55
Premium Analytics: reuse the comparison predicate
retrofox Jul 29, 2026
4f65735
Premium Analytics: name the weekday date format
retrofox Jul 29, 2026
cfa79f7
Premium Analytics: share the DateRange type in formatters
retrofox Jul 29, 2026
dd6a11b
restore page subtitle
retrofox Jul 29, 2026
ac5d550
Merge remote-tracking branch 'origin/trunk' into add/pa-section-heade…
retrofox Jul 30, 2026
29193d2
update pnpm lock file
retrofox Jul 30, 2026
e00e9e8
Revert "update pnpm lock file"
retrofox Jul 30, 2026
189ef4c
drop the length from the all-time subtitle
retrofox Jul 30, 2026
b47ba85
align SectionHeader story with applied state
retrofox Jul 30, 2026
3ebfdcb
Merge remote-tracking branch 'origin/trunk' into add/pa-section-heade…
retrofox Aug 3, 2026
3b2c05c
Merge remote-tracking branch 'origin/trunk' into add/pa-section-heade…
retrofox Aug 3, 2026
81f2818
cover withWeekday directly
retrofox Aug 3, 2026
ed48c69
correct the span docblock examples
retrofox Aug 3, 2026
9d5da37
hold the year surface subtitle still
retrofox Aug 3, 2026
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: added

Dashboard: describe the applied date range, its length, and any active comparison in the section header subtitle.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
formatMetricValue,
formatDate,
formatDateRange,
formatDateRangeLong,
getDateRangeSpan,
type DateFormatName,
} from '@jetpack-premium-analytics/formatters';
```
Expand Down Expand Up @@ -65,12 +67,18 @@ formatDate( date ); // '21 de junio de 2025'
formatDate( date, 'short' ); // '21 de junio'
```

| Name | Purpose |
| -------- | ----------------------------------------------- |
| `medium` | Default. The site's `date_format` verbatim. |
| `short` | The site format minus its year. |
| `year` | Year alone. |
| `iso` | Machine-readable `YYYY-MM-DD`. Never localized. |
| Name | Purpose |
| ------------ | --------------------------------------------------- |
| `medium` | Default. The site's `date_format` verbatim. |
| `short` | The site format minus its year. |
| `full` | The site format led by the weekday. |
| `fullNoYear` | `short` led by the weekday. |
| `year` | Year alone. |
| `iso` | Machine-readable `YYYY-MM-DD`. Never localized. |

The two weekday forms are derived, since core publishes no weekday-bearing
format. The weekday name itself still comes from WordPress's translation
tables; only the comma joining it to the date is ours.

There is no custom-pattern escape hatch: a hand-written pattern fixes the
day/month order to whatever the author happened to type, which is the problem
Expand Down Expand Up @@ -127,6 +135,67 @@ translated WordPress range pattern instead:
| --------- | ---------------------------- | ----------------- |
| `range` | `{ from?: Date; to?: Date }` | Date range object |

## `formatDateRangeLong( range?, options? )`

Format a date range in explicit, readable form, for prominent surfaces such as
the section header subtitle. Returns `''` when range or dates are missing.

The shape follows the range's own length: day-scale ranges lead with the
weekday and omit the year while they sit in the reference year; longer ranges
drop the weekday and always carry the year. A window of a day or less is named
by a single date rather than two endpoints.

```typescript
formatDateRangeLong( { from, to } );
// 24 hours: 'Tuesday, July 28'
// today: 'Wednesday, July 29'
// 7 days: 'Tuesday, July 21 – Monday, July 27'
// past year: 'Tuesday, July 16, 2024 – Monday, July 22, 2024'
// 12 months: 'July 1, 2025 – June 30, 2026'
```

Pass `calendarScale` for a selection whose scale is a property of the selection
rather than of the dates. A calendar year still running ends at the end of
today, so its measured unit, and with it the shape, would otherwise change from
one day to the next:

```typescript
formatDateRangeLong( { from, to }, { calendarScale: true } );
// read mid-month: 'January 1, 2026 – July 30, 2026'
// read on a month end: 'January 1, 2026 – July 31, 2026'
// without the flag, the first of those reads 'Thursday, January 1 – Thursday, July 30'
```

| Parameter | Type | Default | Description |
| ----------------------- | ---------------------------- | ------------ | ---------------------------------------------- |
| `range` | `{ from?: Date; to?: Date }` | | Date range object |
| `options.referenceYear` | `number` | current year | Year against which the year is redundant |
| `options.calendarScale` | `boolean` | `false` | Force the calendar shape whatever it measures |

## `getDateRangeSpan( range? )`

Measure how long a range is, in the coarsest unit that divides it evenly.
Returns `null` when range or dates are missing.

Derived from the range itself rather than the preset that produced it, so a
window stepped back off a preset still reports its own length.

```typescript
getDateRangeSpan( { from, to } );
// 24 hours: { unit: 'hour', value: 24 }
// 7 days: { unit: 'day', value: 7 }
// 12 months: { unit: 'month', value: 12 }
// all time: { unit: 'year', value: 6 }
```

A whole-month range stays in days below two months, so "Last 30 days" does not
become "1 month", and only collapses into years from two years up, so a
twelve-month window keeps reading as "12 months".

| Parameter | Type | Description |
| --------- | ---------------------------- | ----------------- |
| `range` | `{ from?: Date; to?: Date }` | Date range object |

## Implementation

Number and currency formatting delegates to `@automattic/number-formatters`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"dependencies": {
"@automattic/number-formatters": "workspace:*",
"@wordpress/date": "5.51.0",
"@wordpress/i18n": "^6.9.0"
"@wordpress/i18n": "^6.9.0",
"date-fns": "4.1.0"
},
"devDependencies": {
"@date-fns/tz": "1.4.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* External dependencies
*/
import { TZDate } from '@date-fns/tz';
import { setSettings } from '@wordpress/date';
/**
* Internal dependencies
*/
import { EN_US_SETTINGS } from '../__fixtures__/wp-date-settings';
import { formatDateRangeLong } from '../format-date-range-long';

/**
* The zone both frames are pinned to: `date-fns` reads a `TZDate`'s own zone
* for day boundaries, and `dateI18n` renders in the site's. Production keeps
* the two in step because the range carries the site's zone; the fixtures pin
* the site to UTC, so the dates are built there too.
*/
const TEST_TIMEZONE = 'UTC';

/**
* Build the first instant of a day, in the site's zone.
*
* @param year - Full year.
* @param month - 1-indexed month.
* @param day - Day of month.
* @return The date.
*/
function at( year: number, month: number, day: number ): TZDate {
return new TZDate( year, month - 1, day, 0, 0, 0, 0, TEST_TIMEZONE );
}

/**
* The last instant of a day, the shape preset ranges use for `to`.
*
* @param year - Full year.
* @param month - 1-indexed month.
* @param day - Day of month.
* @return The end of that day.
*/
function endOf( year: number, month: number, day: number ): TZDate {
return new TZDate( year, month - 1, day, 23, 59, 59, 999, TEST_TIMEZONE );
}

/**
* An instant part-way through a day, for the rolling-window cases.
*
* @param year - Full year.
* @param month - 1-indexed month.
* @param day - Day of month.
* @param hour - Hour of day.
* @param minute - Minute of hour.
* @return The date.
*/
function atTime( year: number, month: number, day: number, hour: number, minute = 0 ): TZDate {
return new TZDate( year, month - 1, day, hour, minute, 0, 0, TEST_TIMEZONE );
}

describe( 'formatDateRangeLong', () => {
beforeEach( () => {
setSettings( EN_US_SETTINGS );
} );

it( 'returns an empty string when the range is incomplete', () => {
expect( formatDateRangeLong() ).toBe( '' );
expect( formatDateRangeLong( {} ) ).toBe( '' );
expect( formatDateRangeLong( { from: at( 2026, 7, 21 ) } ) ).toBe( '' );
} );

it( 'leads day-scale ranges with the weekday and omits the current year', () => {
expect(
formatDateRangeLong(
{ from: at( 2026, 7, 21 ), to: endOf( 2026, 7, 27 ) },
{ referenceYear: 2026 }
)
).toBe( 'Tuesday, July 21 – Monday, July 27' );
} );

it( 'names a day-aligned single day once instead of repeating it', () => {
expect(
formatDateRangeLong(
{ from: at( 2026, 7, 29 ), to: endOf( 2026, 7, 29 ) },
{ referenceYear: 2026 }
)
).toBe( 'Wednesday, July 29' );
} );

it( 'names a rolling 24-hour window by the day it starts on', () => {
// The window straddles two calendar days without being about either in
// full, so naming both ends would overstate its reach.
expect(
formatDateRangeLong(
{
from: atTime( 2026, 7, 28, 14, 30 ),
to: atTime( 2026, 7, 29, 14, 30 ),
},
{ referenceYear: 2026 }
)
).toBe( 'Tuesday, July 28' );
} );

it( 'keeps both ends for a window longer than a day', () => {
// 36 hours is still hour-scale, but no single date covers it.
expect(
formatDateRangeLong(
{
from: atTime( 2026, 7, 28, 6 ),
to: atTime( 2026, 7, 29, 18 ),
},
{ referenceYear: 2026 }
)
).toBe( 'Tuesday, July 28 – Wednesday, July 29' );
} );

it( 'adds the year once a day-scale range leaves the reference year', () => {
expect(
formatDateRangeLong(
{ from: at( 2024, 7, 16 ), to: endOf( 2024, 7, 22 ) },
{ referenceYear: 2026 }
)
).toBe( 'Tuesday, July 16, 2024 – Monday, July 22, 2024' );
} );

it( 'adds the year when a day-scale range straddles two years', () => {
expect(
formatDateRangeLong(
{ from: at( 2025, 12, 29 ), to: endOf( 2026, 1, 4 ) },
{ referenceYear: 2026 }
)
).toBe( 'Monday, December 29, 2025 – Sunday, January 4, 2026' );
} );

it( 'drops the weekday and always carries the year on month-scale ranges', () => {
expect(
formatDateRangeLong(
{ from: at( 2025, 7, 1 ), to: endOf( 2026, 6, 30 ) },
{ referenceYear: 2026 }
)
).toBe( 'July 1, 2025 – June 30, 2026' );
} );

it( 'defaults the reference year to the current one', () => {
const year = new Date().getFullYear();

expect(
formatDateRangeLong( { from: at( year, 7, 21 ), to: endOf( year, 7, 27 ) } )
).not.toMatch( String( year ) );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ describe( 'formatDate', () => {
it( 'formats "iso" as a machine-readable date', () => {
expect( formatDate( JUNE_21, 'iso' ) ).toBe( '2025-06-21' );
} );

it( 'leads "full" with the weekday', () => {
expect( formatDate( JUNE_21, 'full' ) ).toBe( 'Saturday, June 21, 2025' );
} );

it( 'leads "fullNoYear" with the weekday and drops the year', () => {
expect( formatDate( JUNE_21, 'fullNoYear' ) ).toBe( 'Saturday, June 21' );
} );
} );

describe( 'es_ES site', () => {
Expand All @@ -61,4 +69,10 @@ describe( 'formatDate', () => {

expect( formatDate( JUNE_21, 'short' ) ).toBe( '2025' );
} );

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

expect( formatDate( JUNE_21, 'full' ) ).toBe( 'Saturday, June 21, 2025' );
} );
} );
Loading
Loading