Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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: add a per-section header with the section title and date controls.
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,
} from '@jetpack-premium-analytics/formatters';
```

Expand Down Expand Up @@ -57,7 +59,7 @@ formatDate( new Date( '2025-06-21' ), 'long' ); // 'June 21, 2025'
formatDate( new Date( '2025-06-21' ), 'dd/MM/yyyy' ); // '21/06/2025'
```

**Named presets:** `short`, `medium` (default), `long`, `full`, `day`, `month`, `year`, `monthYear`, `numeric`, `iso`, `dateTime`.
**Named presets:** `short`, `medium` (default), `long`, `full`, `fullNoYear`, `day`, `month`, `year`, `monthYear`, `numeric`, `iso`, `dateTime`.

## `formatDateRange( range? )`

Expand All @@ -76,6 +78,54 @@ formatDateRange( { from, to } );
| --------- | ---------------------------- | ----------------- |
| `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'
```

| Parameter | Type | Default | Description |
| ----------------------- | ---------------------------- | ------------ | ---------------------------------------- |
| `range` | `{ from?: Date; to?: Date }` | | Date range object |
| `options.referenceYear` | `number` | current year | Year against which the year is redundant |

## `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 |

## Architecture

Number and currency formatting delegates to `@automattic/number-formatters`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Internal dependencies
*/
import { formatDateRangeLong } from '../format-date-range-long';

/**
* Build a local-time date, so day boundaries land in the frame `date-fns` reads.
*
* @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 ): Date {
return new Date( year, month - 1, day, 0, 0, 0, 0 );
}

/**
* 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 ): Date {
return new Date( year, month - 1, day, 23, 59, 59, 999 );
}

describe( 'formatDateRangeLong', () => {
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: new Date( 2026, 6, 28, 14, 30 ),
to: new Date( 2026, 6, 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: new Date( 2026, 6, 28, 6, 0 ),
to: new Date( 2026, 6, 29, 18, 0 ),
},
{ 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: new Date( year, 6, 21 ), to: new Date( year, 6, 27 ) } )
).not.toMatch( String( year ) );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Internal dependencies
*/
import { getDateRangeSpan } from '../get-date-range-span';

/**
* Build a local-time date. Dates are constructed from parts rather than parsed
* from ISO strings so the day boundaries land in the machine's timezone, which
* is the frame `date-fns` reads.
*
* @param year - Full year.
* @param month - 1-indexed month.
* @param day - Day of month.
* @param hours - Hour of day.
* @param minutes - Minute of hour.
* @return The date.
*/
function at( year: number, month: number, day: number, hours = 0, minutes = 0 ): Date {
return new Date( year, month - 1, day, hours, minutes, 0, 0 );
}

/**
* 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 ): Date {
return new Date( year, month - 1, day, 23, 59, 59, 999 );
}

describe( 'getDateRangeSpan', () => {
it( 'returns null when the range is incomplete', () => {
expect( getDateRangeSpan() ).toBeNull();
expect( getDateRangeSpan( {} ) ).toBeNull();
expect( getDateRangeSpan( { from: at( 2026, 7, 21 ) } ) ).toBeNull();
} );

it( 'counts whole days inclusively', () => {
expect( getDateRangeSpan( { from: at( 2026, 7, 21 ), to: endOf( 2026, 7, 27 ) } ) ).toEqual( {
unit: 'day',
value: 7,
} );
expect( getDateRangeSpan( { from: at( 2026, 7, 29 ), to: endOf( 2026, 7, 29 ) } ) ).toEqual( {
unit: 'day',
value: 1,
} );
} );

it( 'measures a rolling sub-day window in hours', () => {
// `last-24-hours` ends at the current time, not at a day boundary.
expect(
getDateRangeSpan( { from: at( 2026, 7, 28, 14, 30 ), to: at( 2026, 7, 29, 14, 30 ) } )
).toEqual( { unit: 'hour', value: 24 } );
} );

it( 'counts a day-aligned range in days even when it is only one day long', () => {
// `today` also spans 24 hours, but its boundaries make it a calendar day.
expect( getDateRangeSpan( { from: at( 2026, 7, 29 ), to: endOf( 2026, 7, 29 ) } ) ).toEqual( {
unit: 'day',
value: 1,
} );
} );

it( 'describes whole-month ranges in months', () => {
// Month-aligned, as the design mock shows it.
expect( getDateRangeSpan( { from: at( 2025, 7, 1 ), to: endOf( 2026, 6, 30 ) } ) ).toEqual( {
unit: 'month',
value: 12,
} );

// Rolling, as `last-12-months` actually produces it.
expect( getDateRangeSpan( { from: at( 2025, 7, 29 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( {
unit: 'month',
value: 12,
} );
} );

it( 'collapses multi-year ranges into years', () => {
// The year surface's "All time" spans several calendar years, which would
// otherwise read as an unusable month count.
expect( getDateRangeSpan( { from: at( 2020, 1, 1 ), to: endOf( 2025, 12, 31 ) } ) ).toEqual( {
unit: 'year',
value: 6,
} );
} );

it( 'keeps a twelve-month range in months, as the design spells it', () => {
expect( getDateRangeSpan( { from: at( 2025, 7, 1 ), to: endOf( 2026, 6, 30 ) } ) ).toEqual( {
unit: 'month',
value: 12,
} );
} );

it( 'keeps a whole-year count in months when it does not divide into years', () => {
expect( getDateRangeSpan( { from: at( 2024, 1, 1 ), to: endOf( 2026, 6, 30 ) } ) ).toEqual( {
unit: 'month',
value: 30,
} );
} );

it( 'keeps short whole-month ranges in days', () => {
// 30 days is a whole month, but "Last 30 days" should not say "1 month".
expect( getDateRangeSpan( { from: at( 2026, 6, 29 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( {
unit: 'day',
value: 30,
} );
} );

it( 'falls back to days when the range does not divide into months', () => {
expect( getDateRangeSpan( { from: at( 2026, 4, 30 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( {
unit: 'day',
value: 90,
} );
} );

it( 'does not treat a short final month as a whole month', () => {
// `addMonths` clamps Jan 31 to Feb 28, which a day-of-month comparison
// would read as a whole month.
expect( getDateRangeSpan( { from: at( 2026, 1, 31 ), to: endOf( 2026, 3, 27 ) } ) ).toEqual( {
unit: 'day',
value: 56,
} );
} );
} );
Loading
Loading