Skip to content

Premium Analytics: format dates with the site locale instead of hardcoded US patterns - #50888

Merged
chihsuan merged 6 commits into
trunkfrom
wooa7s-1766-locale-aware-dates
Jul 30, 2026
Merged

chihsuan merged 6 commits into
trunkfrom
wooa7s-1766-locale-aware-dates

Conversation

@chihsuan

@chihsuan chihsuan commented Jul 29, 2026 •

Copy link
Copy Markdown
Member

Fixes WOOA7S-1766

Proposed changes

Premium Analytics currently formats dates with hardcoded US-English patterns or the browser locale. As a result, dates can disagree with the language, date format, and timezone used elsewhere in wp-admin.

This change makes the affected dashboard dates follow WordPress's date settings:

  • Format dates with @wordpress/date, using WordPress's translated month and weekday names and the site's date_format.
  • Replace the unused formatter presets with four named formats:
    • medium: the site's complete date format.
    • short: the site format without its year, falling back to the complete format when nothing remains.
    • year: the year only.
    • iso: the non-localized YYYY-MM-DD format used by query parameters.
  • Remove custom format strings so call sites cannot accidentally hardcode a locale-specific day/month order.
  • Render both dates in a range instead of applying English-only month or year elision, and make the complete range pattern translatable.
  • Parse timezone-less Stats API timestamps as site-local MySQL datetimes before formatting them.
  • Use WordPress's existing site-timezone parser, including for sites configured with a manual UTC offset.
  • Keep date-input parsing and serialization in the same explicit timezone so YYYY-MM-DD values round-trip without changing days.
  • Fall back to readable current/previous-period legend labels when URL date params are invalid.
  • Update affected consumers, tests, formatter documentation, and the chart tick formatter type.

Reviewer focus

The short format is derived from an arbitrary PHP date format by removing its year. The tokenizer must preserve escaped literals: for example, the Spanish format j \d\e F \d\e Y uses \d\e to spell "de", even though d and e are also PHP date tokens. It must also retain the ordinal dot used by formats such as Finnish j.n.Y.

The display formatter treats its input as an instant and renders that instant in the site timezone. Its parameter type accepts only a Date or a timestamp, so a site-local string cannot be passed by accident — those go through parseSiteDateTime — while the date input uses its explicit picker timezone for both parsing and serialization.

The X-axis tickFormat prop is now limited to named date formats, but no current production caller opts into it. When omitted, the chart library's default tick labels remain in use. WOOA7S-1827 tracks localizing those ticks with separate visual review.

This PR updates the consumers required for WOOA7S-1766. The post summary, video summary, and latest-post widget still have independent date-fns formatting and should be migrated separately.

A third group of consumers already went through formatDate, so their output changes without appearing in the diff: the comparative-line tooltip and the metric-tabs-chart subtitle move from Jun 21, 2025 to June 21, 2025 (the old medium preset was MMM d, yyyy, the new one is the site's date_format), the metric-tabs-chart subtitle and the post-views legend spell out both ends of a range, and the Emails Sent column stops following the browser locale. None of these are wrong, but the cells and tooltips get wider and the steps below do not cover them. #50893 reintroduces range elision where the locale allows it, so the wider ranges are temporary; the long-form single dates are not.

The data layer keeps its own site-timezone accessor, getSiteTimezone(), which resolves from the core-data root/site entity rather than from the settings the formatters render in, and falls back to the browser zone until that entity loads. Only its zero-offset guard is fixed here; collapsing the two accessors touches query construction across the data layer and needs its own testing pass, so WOOA7S-1820 tracks it along with localTZDate() reading date-only values as UTC.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

Set the browser and the site to timezones that disagree, so an accidental conversion becomes visible. These steps assume an Asia/Taipei browser; any zone works as long as it differs from the site's.

One invariant runs through all of it: the calendar day shown for a given record must never move when the site timezone changes. The Stats API returns timezone-less, site-local timestamps, so the rendered day must follow the site's zone setting and must not be re-derived from the browser's.

Each group changes one setting at a time, so a failure points at a single cause.

Baseline

  1. Go to Settings → General. Set Timezone to Europe/Amsterdam and Date Format to F j, Y.
  2. Go to Jetpack → Analytics → Reports → Emails. Note the date in the Sent column — for example July 9, 2026. Keep it; the timezone group below checks against this exact value.
  3. Confirm it uses the site format, rather than the browser-locale or abbreviated Jul 9, 2026 form.

Date format

Leave the timezone at Europe/Amsterdam and change only Settings → General → Date Format.

  1. d/m/Y — the Sent column reads 09/07/2026: day-first, taken from the site format.
  2. j \d\e F \d\e Y, the format Spanish sites ship — the column reads 9 de July de 2026. The month name stays English because the site language has not changed. What this checks is that the day-first ordering comes from the site format, and that the escaped \d and \e render as the word "de" rather than as the day and timezone tokens.
  3. j.n.Y, the Finnish format — on Jetpack → Analytics → Insights, the Most popular day widget keeps its trailing month dot, for example 9.7., instead of losing it along with the year.
  4. Y — the same widget shows a readable year rather than an ISO timestamp.
Screenshot 2026-07-29 at 5 24 34 PM

Steps 5 and 6 have an objective oracle, so they need not be eyeballed. Ask WordPress itself what the format should produce:

wp eval 'echo date_i18n( get_option( "date_format" ), strtotime( "2026-07-09 09:42:57" ) );'

The dashboard should print exactly what PHP prints.

Ranges

  1. Set Date Format back to F j, Y. Open the dashboard date-range picker and select a comparison preset. Compare to: should spell out both ends, for example May 30, 2026 – June 28, 2026.
  2. Set Date Format to d/m/Y and confirm the same range follows it, for example 30/05/2026 – 28/06/2026. This exercises the range formatter together with the tokenizer, which the single-date steps above do not.
  3. Open a post from Most viewed to reach the post detail screen. The Post views chart legend should use the site format. Its X-axis tick labels deliberately do not — those still come from the chart library's own formatter, so the mismatch on this screen is expected rather than a bug. See Reviewer focus and WOOA7S-1827.
Screenshot 2026-07-29 at 5 23 36 PM Screenshot 2026-07-29 at 5 22 52 PM

Timezone

Return Date Format to F j, Y and change only Settings → General → Timezone. The Sent value recorded in step 2 must stay on the same calendar day for every zone below.

  1. UTC+0 — reopen the date-range picker, select a date such as July 9, and confirm the field stays 2026-07-09 even though the browser is in Asia/Taipei.
  2. Pacific/Midway — the Sent column still shows the day from step 2. This is the load-bearing case: at UTC−11 it is the only zone here on the far side of UTC, 19 hours from UTC+8, so a timestamp parsed as UTC or as browser-local lands on the previous day here and nowhere else in this list.
  3. A manual offset of UTC-11 — dates still render instead of falling back to an em dash. This is a different path from step 12 even at the same offset: choosing a manual offset leaves timezone_string empty, so the zone has to come from gmt_offset alone.

Prefer a negative manual offset to UTC+8 here. UTC+8 is the same as the suggested browser timezone, so it cannot distinguish a correctly anchored value from one parsed as browser-local.

Changing Site Language to a non-English locale additionally shows translated month names, for example 15 de julio de 2026 – 21 de julio de 2026. It is not required to review this PR, and the dashboard currently stalls for a long time on non-English locales for unrelated reasons — see WOOA7S-1822.

Locally verified with the site set to Europe/Amsterdam, UTC+0, Pacific/Midway, and a manual positive offset, while the browser used Asia/Taipei. Regression tests also run with TZ=Asia/Taipei.

@chihsuan chihsuan added Bug When a feature is broken and / or not performing as intended [Status] In Progress labels Jul 29, 2026
@chihsuan chihsuan self-assigned this Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the wooa7s-1766-locale-aware-dates branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack wooa7s-1766-locale-aware-dates
bin/jetpack-downloader test jetpack-mu-wpcom-plugin wooa7s-1766-locale-aware-dates

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@jp-launch-control

jp-launch-control Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 6 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts 12/12 (100.00%) 0.00% 0 💚
projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx 10/23 (43.48%) 2.57% 0 💚
projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts 7/7 (100.00%) 6.25% -1 💚
projects/packages/premium-analytics/packages/datetime/src/tz.ts 12/17 (70.59%) 11.76% -2 💚
projects/packages/premium-analytics/widgets/most-popular-day/render.tsx 0/14 (0.00%) 0.00% -4 💚

3 files are newly checked for coverage.

File Coverage
projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts 9/10 (90.00%) 💚
projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts 10/10 (100.00%) 💚
projects/packages/premium-analytics/packages/formatters/src/date/php-format.ts 33/33 (100.00%) 💚

Full summary · PHP report · JS report

@chihsuan
chihsuan requested a review from retrofox July 29, 2026 09:29
@chihsuan
chihsuan marked this pull request as ready for review July 29, 2026 09:29
@chihsuan
chihsuan requested a review from a team July 29, 2026 09:29
@chihsuan
chihsuan requested a review from a team as a code owner July 29, 2026 09:29
@chihsuan chihsuan added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels Jul 29, 2026
* `from`/`to` (e.g. a hand-edited or under-encoded deep link where the `+`
* offset decoded to a space) must not become an invalid Date — `formatDate`
* throws "Invalid time value" on one and would white-screen the page.
* offset decoded to a space) must not become an invalid Date and leak an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is now wrong, and wrong in the direction that gets the guard deleted.

formatDate no longer throws; correct: dateI18n on an invalid Date returns the string "Invalid date".

But the other consumer of this range does throw. DateRangeInput stopped calling formatDate in this PR and now calls formatToTimezoneNaiveString, which does throw new Error( 'Invalid date provided' ) at packages/datetime/src/tz.ts:86.

So the white-screen path is intact; it just moved packages. Downgrading the stated consequence to a cosmetic label sets up someone reading this in six months to conclude that the isValid filter is decoration.

Suggested change
* offset decoded to a space) must not become an invalid Date and leak an
* offset decoded to a space) must not become an invalid Date: the picker's
* `formatToTimezoneNaiveString` throws on one and would white-screen the page,
* and the trigger label would read "Invalid date".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

* Adjusts output based on whether dates share the same day, month, or year.
*
* Both ends are spelled out because WordPress provides no locale-specific
* range-elision rules.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NonNullable< Parameters< typeof dateI18n >[ 1 ] > resolves to Moment | Date | string | number, so formatDate( '2026-01-01' ) compiles and returns the previous day for every visitor ahead of the site.

The PR removes the custom-pattern escape hatch to stop callers from freezing the day/month order by hand. That hatch produced a wrong order. This one produces a wrong day, and it's the failure the docblock right above spends three lines describing in prose.

Date | number closes it in the compiler with no caller loss: the only non-Date call site is the chart at line 302, which passes a timestamp.

type DateInput = Date | number;

parseSiteDateTime already exists to get from a string to a Date, so the narrow type routes people to it instead of documenting the trap.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, narrowed to Date | number.

axis: {
x: {
// Use the chart library's default behavior for 'custom' presets
tickFormat: xTickFormatType ? xTickFormat : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a note on this line now that it carries more weight than it used to.

With ?? 'short' gone from line 302, this ternary is the only thing keeping the chart's own tick formatter.

formatDate's default is 'medium', not 'short', so if the guard ever collapses to tickFormat: xTickFormat, the X axis silently goes from the library default to the site's full date format on every tick.

The existing comment explains the 'custom' preset case, not this one. Something like "must stay conditional: formatDate defaults to medium, so an unconditional xTickFormat would put full site-format dates on every tick" would survive the next refactor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Replaced it with the reason the ternary has to stay conditional.

[]
);

const renderTooltip = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor:

The description lists post summary, video summary, and latest-post as consumers left on their own date-fns formatting.

There's a third group worth naming: call sites that have already gone through formatDate and therefore change their output without appearing in the diff.

This tooltip is one. On a default F j, Y site, it goes from Jun 21, 2025 to June 21, 2025.

Same for metric-tabs-chart.tsx:85 and the post-views widget legend, which move to full both-ends ranges, and for the Emails Sent column, which additionally stops following the browser locale.

None of these are wrong 👍. They're just cells and tooltips that get wider, and none of them are covered by the testing steps.

"@wordpress/compose": "8.4.0",
"@wordpress/data": "10.51.0",
"@wordpress/dataviews": "17.1.0",
"@wordpress/date": "5.51.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wordpress/date is test-only in this package.

The single import is in src/helpers/__tests__/format-legend-labels.test.ts; the helper itself reaches parseSiteDateTime through @jetpack-premium-analytics/datetime.

The package has a devDependencies block. No bundle impact either way, but the manifest currently claims a runtime dependency that doesn't exist.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, moved to devDependencies.

Comment on lines +30 to +31
const formatToString = ( date: Date | undefined, timeZone: string ) =>
date ? formatToTimezoneNaiveString( date, timeZone ).slice( 0, 10 ) : '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.slice( 0, 10 ) is getDatePart from packages/datetime/src/date.ts:20, which is exported from the package this PR just added to routes/reports' dependencies.

Switching to the picker's own timezone here is the right call; that part isn't in question. something like...

Suggested change
const formatToString = ( date: Date | undefined, timeZone: string ) =>
date ? formatToTimezoneNaiveString( date, timeZone ).slice( 0, 10 ) : '';
const formatToString = ( date: Date | undefined, timeZone: string ) =>
date ? getDatePart( formatToTimezoneNaiveString( date, timeZone ) ) ?? '' : '';

Nested ternary btw 😬

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed!

retrofox
retrofox previously approved these changes Jul 29, 2026

@retrofox retrofox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Chi. Nice enhancement. Approving ✅

The tokenizer holds up, and the two things the diff doesn't make visible check out.

I ran withoutYear against the date_format strings the core language packs ship, not just the ones in the test file. 36 formats, all correct, including the ones that break naive
tokenizers: Y \m. F j \d. (lt_LT), Y. \g\a\d\a j. F (lv_LV), Y\k\o F\r\e\n j\a (eu), Y. F j. (hu_HU), j. F Y. (hr). The dot-retention rule is what makes fi/cs/de work without
dragging in punctuation from formats where the dot floats.

The parseSiteDateTime to formatDate round-trip is DST-safe. getDate() anchors to the synthetic WP zone while dateI18n() renders through the IANA string, so a fixed-offset WP zone would shift near-midnight timestamps by a day in the opposite half of the year. setupWPTimezone clones the real IANA zone data (GB #48083).

Checked against WP 7.0.2 and Gutenberg's wp/6.9, our declared minimum.

@wordpress/date matches wp-build's /^@wordpress\// externals pattern, so it lands on core's wp-date handle and core's inline setSettings() applies. Moment stays out of the bundle.

Suite green under TZ=Asia/Taipei and TZ=Pacific/Midway: 200 suites, 1369 tests.

Notes inline. Two are worth acting on; the rest are follow-up.

Narrow `formatDate` to `Date | number` so a date-only string cannot compile:
`dateI18n` reads one as browser-local midnight and renders the previous day
for anyone ahead of the site.

Also restore the white-screen consequence in the picker range docblock, note
why the chart's tick-format ternary must stay conditional, use `getDatePart`
instead of a magic slice, and move the toolkit's test-only `@wordpress/date`
to devDependencies.
@chihsuan

Copy link
Copy Markdown
Member Author

Thanks for the review! @retrofox 🙌

@chihsuan
chihsuan merged commit b6ab04f into trunk Jul 30, 2026
170 of 171 checks passed
@chihsuan
chihsuan deleted the wooa7s-1766-locale-aware-dates branch July 30, 2026 05:24
@github-actions github-actions Bot added [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. and removed [Status] Needs Review This PR is ready for review. labels Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug When a feature is broken and / or not performing as intended Docs [Package] Premium Analytics [Status] UI Changes Add this to PRs that change the UI so documentation can be updated.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants