@automattic/charts 3.2.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,49 +1,26 @@
1
1
  /**
2
- * @file Date parsing utilities using date-fns for local timezone handling
2
+ * @file Date parsing: a naive string is dated in a supplied IANA zone, or the runtime's own
3
3
  *
4
- * This module provides utilities for parsing various date string formats and converting
5
- * them to local timezone dates using the battle-tested date-fns library. For formats
6
- * without timezone info, they're treated as local. For formats with timezone info,
7
- * they're converted to the equivalent local time.
4
+ * A string carrying an offset is already an instant and parses the same everywhere. A string
5
+ * without one is only a wall-clock reading, so it means nothing until a zone is named. See
6
+ * `parseAsLocalDate` for the supported formats.
8
7
  *
9
- * Note: And specifically it prevents format `YYYY-MM-DD` being parsed as UTC date.
10
- *
11
- * Key Features:
12
- * - All parsed dates are in local timezone
13
- * - Converts timezone-aware strings to local equivalent
14
- * - Robust input validation and error handling using date-fns
15
- * - TypeScript type safety
16
- * - Much smaller codebase than custom parsing
17
- *
18
- * Supported Formats:
19
- * - YYYY-MM-DD (treated as local)
20
- * - YYYY-MM-DD HH:mm:ss (treated as local)
21
- * - YYYY-MM-DD HH:mm (treated as local)
22
- * - YYYY-MM-DDTHH:mm:ss (treated as local)
23
- * - YYYY-MM-DDTHH:mm:ss.SSS (treated as local)
24
- * - YYYY-MM-DDTHH:mm (treated as local)
25
- * - YYYY-MM-DDTHH:mm:ssZ (converted to local)
26
- * - YYYY-MM-DDTHH:mm:ss±HH:mm (converted to local)
27
- *
28
- * @example
29
- * ```typescript
30
- * parseAsLocalDate("2025-01-01"); // Local timezone
31
- * parseAsLocalDate("2025-01-01 14:30:00"); // Local timezone
32
- * parseAsLocalDate("2025-01-01 14:30"); // Local timezone
33
- * parseAsLocalDate("2025-01-01T14:30:45.123"); // Local timezone
34
- * parseAsLocalDate("2025-01-01T14:30:00Z"); // UTC 14:30 → Local equivalent
35
- * parseAsLocalDate("2025-01-01T14:30:00+05:00"); // +05:00 14:30 → Local equivalent
36
- * ```
8
+ * Note: this specifically avoids date-fns's default of parsing `YYYY-MM-DD` as a UTC date.
37
9
  */
38
10
 
11
+ import { tzOffset } from '@date-fns/tz';
39
12
  import { parse, parseISO, isValid } from 'date-fns';
13
+ import { warnOnce } from './warn-once';
40
14
 
41
15
  /**
42
16
  * Checks if a date string contains timezone information
17
+ *
18
+ * The minutes are optional because ISO 8601 allows a bare `±hh`, which `parseISO` reads.
19
+ *
43
20
  * @param {string} dateString - The date string to check for timezone information
44
21
  * @return {boolean} True if the date string contains timezone information, false otherwise
45
22
  */
46
- const hasTimezone = ( dateString: string ): boolean => {
23
+ export const hasTimezone = ( dateString: string ): boolean => {
47
24
  const tIndex = dateString.indexOf( 'T' );
48
25
  if ( tIndex === -1 ) {
49
26
  return false;
@@ -53,29 +30,93 @@ const hasTimezone = ( dateString: string ): boolean => {
53
30
  return true;
54
31
  }
55
32
 
56
- return /[+-]\d{2}:?\d{2}$/.test( dateString.slice( tIndex + 1 ) );
33
+ return /[+-]\d{2}(?::?\d{2})?$/.test( dateString.slice( tIndex + 1 ) );
34
+ };
35
+
36
+ // The wall clock, read off the string rather than back out of the parsed `Date`: date-fns
37
+ // builds that with local setters, so a runtime zone whose own DST gap swallows the reading
38
+ // hands back fields an hour out. Covers every naive shape in `formats` below, at the one to
39
+ // N digits date-fns reads each field as; it takes a short fraction literally, so `.5` is 5ms.
40
+ const NAIVE =
41
+ /^(\d{1,4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.(\d{1,3}))?)?)?$/;
42
+
43
+ // `Date.UTC` reads a year below 100 as 1900 + year, which would silently re-date the first
44
+ // century. `setUTCFullYear` has no such mapping.
45
+ const asUtcMs = ( [ , ...fields ]: RegExpExecArray ): number => {
46
+ const [ year, month, day, hour, minute, second, ms ] = fields.map( field =>
47
+ Number( field ?? 0 )
48
+ );
49
+ const date = new Date( 0 );
50
+ date.setUTCFullYear( year, month - 1, day );
51
+ date.setUTCHours( hour, minute, second, ms );
52
+ return date.getTime();
53
+ };
54
+
55
+ /**
56
+ * The instant a wall-clock reading names in a given zone.
57
+ *
58
+ * A reading a spring-forward gap deleted moves forward past the gap, and one a fall-back
59
+ * repeated resolves to a single instant. Neither is configurable: see CHARTS-268.
60
+ *
61
+ * @param wallClock - The reading, as the milliseconds it would be if it were UTC.
62
+ * @param timeZone - IANA zone the reading is dated in.
63
+ * @return The instant, or `null` where `timeZone` is not a zone Intl accepts.
64
+ */
65
+ const wallClockToInstant = ( wallClock: number, timeZone: string ): Date | null => {
66
+ // `tzOffset` answers NaN for a zone Intl cannot use. Falling back to the runtime's zone
67
+ // keeps the contract that this never throws, but it silently reinstates the very defect
68
+ // the argument exists to fix, so say so where a developer can see it.
69
+ const first = tzOffset( timeZone, new Date( wallClock ) );
70
+
71
+ if ( Number.isNaN( first ) ) {
72
+ warnOnce(
73
+ `parse:timeZone:${ timeZone }`,
74
+ `timeZone ${ JSON.stringify(
75
+ timeZone
76
+ ) } is not a zone Intl accepts, so dates are read in the browser's zone. Pass an IANA name or a UTC offset such as "+05:30".`
77
+ );
78
+ return null;
79
+ }
80
+
81
+ // The offset depends on the instant, so the first answer is only a guess: a DST
82
+ // transition between the two moves it. Re-reading the offset at the guess settles
83
+ // every reading the zone actually has.
84
+ const guess = wallClock - first * 60000;
85
+ const second = tzOffset( timeZone, new Date( guess ) );
86
+
87
+ if ( first === second ) {
88
+ return new Date( guess );
89
+ }
90
+
91
+ const corrected = wallClock - second * 60000;
92
+ const third = tzOffset( timeZone, new Date( corrected ) );
93
+
94
+ if ( second === third ) {
95
+ return new Date( corrected );
96
+ }
97
+
98
+ // The smaller offset moves the reading forward past the gap, so a day bucket in a zone
99
+ // that springs forward at midnight keeps its own calendar day.
100
+ return new Date( wallClock - Math.min( second, third ) * 60000 );
57
101
  };
58
102
 
59
103
  /**
60
- * Parses any supported date string format and returns a local timezone date
104
+ * Parses any supported date string format into an instant, dated in `timeZone` or the runtime's own
61
105
  *
62
- * Uses date-fns for robust parsing and validation. For strings without timezone
63
- * info, treats as local timezone. For strings with timezone info, converts to
64
- * local timezone equivalent.
106
+ * Uses date-fns for robust parsing and validation. A string carrying timezone info
107
+ * is already an instant and is returned as one, whatever `timeZone` says. A string
108
+ * without it is a wall-clock reading, dated in `timeZone` when one is supplied and
109
+ * in the runtime's own zone when none is.
65
110
  *
66
- * Supports:
67
- * - YYYY-MM-DD (local)
68
- * - YYYY-MM-DD HH:mm:ss (local)
69
- * - YYYY-MM-DD HH:mm (local)
70
- * - YYYY-MM-DDTHH:mm:ss (local)
71
- * - YYYY-MM-DDTHH:mm:ss.SSS (local)
72
- * - YYYY-MM-DDTHH:mm (local)
73
- * - YYYY-MM-DDTHH:mm:ssZ (UTC → local)
74
- * - YYYY-MM-DDTHH:mm:ss±HH:mm (offset → local)
75
- * @param {string} dateString - The date string to parse into a local timezone date
76
- * @return {Date} A Date object representing the parsed date in local timezone, or an invalid Date if parsing fails
111
+ * A wall clock a DST gap deleted is moved forward past the gap; one a fall-back repeated
112
+ * resolves to a single instant. Neither policy is selectable. A zone `Intl` rejects falls
113
+ * back to the runtime's own, and warns once outside production.
114
+ *
115
+ * @param {string} dateString - The date string to parse into a date
116
+ * @param {string} [timeZone] - IANA zone a naive string is read in; the runtime's own when absent, and ignored by a string that carries its own offset
117
+ * @return {Date} A Date object representing the parsed instant, or an invalid Date if parsing fails
77
118
  */
78
- export const parseAsLocalDate = ( dateString: string ): Date => {
119
+ export const parseAsLocalDate = ( dateString: string, timeZone?: string ): Date => {
79
120
  const trimmedString = dateString.trim();
80
121
 
81
122
  // If it has timezone information, parse as ISO and convert to local
@@ -101,10 +142,20 @@ export const parseAsLocalDate = ( dateString: string ): Date => {
101
142
  ];
102
143
 
103
144
  for ( const format of formats ) {
145
+ // date-fns still decides whether the string is a date at all, calendar rules included;
146
+ // only the fields it read are untrustworthy, and only when a zone was named.
104
147
  const result = parse( trimmedString, format, new Date() );
105
- if ( isValid( result ) ) {
148
+ if ( ! isValid( result ) ) {
149
+ continue;
150
+ }
151
+
152
+ if ( ! timeZone ) {
106
153
  return result;
107
154
  }
155
+
156
+ const fields = NAIVE.exec( trimmedString );
157
+
158
+ return ( fields && wallClockToInstant( asUtcMs( fields ), timeZone ) ) ?? result;
108
159
  }
109
160
 
110
161
  // If no format matched, return invalid date
@@ -0,0 +1,33 @@
1
+ import { getStringWidth } from '@visx/text';
2
+ import type { TickFormatter } from '@visx/axis';
3
+ import type { AnyD3Scale, ScaleInput } from '@visx/scale';
4
+
5
+ /**
6
+ * Rendered widths of the first and last tick labels on an axis.
7
+ *
8
+ * An unmeasurable label reserves nothing, which is what a width of 0 already
9
+ * means to every caller, so it is reported as 0 rather than as its own case.
10
+ *
11
+ * @param ticks - Tick values, in axis order.
12
+ * @param formatTick - Function to format a tick.
13
+ * @param {object} labelStyle - Style object for the label.
14
+ * @return {object} - Widths in pixels.
15
+ */
16
+ export const getEdgeTickWidths = < T extends AnyD3Scale >(
17
+ ticks: ScaleInput< T >[],
18
+ formatTick?: TickFormatter< ScaleInput< T > >,
19
+ labelStyle?: object
20
+ ): { first: number; last: number } => {
21
+ if ( ! ticks.length ) {
22
+ return { first: 0, last: 0 };
23
+ }
24
+
25
+ const lastIndex = ticks.length - 1;
26
+ const label = ( tick: ScaleInput< T >, index: number ) =>
27
+ String( formatTick ? formatTick( tick, index, [] ) ?? '' : tick );
28
+
29
+ return {
30
+ first: getStringWidth( label( ticks[ 0 ], 0 ), labelStyle ) ?? 0,
31
+ last: getStringWidth( label( ticks[ lastIndex ], lastIndex ), labelStyle ) ?? 0,
32
+ };
33
+ };
@@ -1,6 +1,9 @@
1
1
  // Chart composition utilities
2
2
  export { attachSubComponents } from './create-composition';
3
3
 
4
+ // Date bucket classification
5
+ export { getBucketInfo } from './bucket-info';
6
+
4
7
  // Date parsing utilities
5
8
  export { parseAsLocalDate } from './date-parsing';
6
9
 
@@ -10,6 +13,7 @@ export type { MetricValueType } from './format-metric-value';
10
13
  export { formatPercentage } from './format-percentage';
11
14
 
12
15
  // Chart measurement utilities
16
+ export { getEdgeTickWidths } from './get-edge-tick-widths';
13
17
  export { getLongestTickWidth } from './get-longest-tick-width';
14
18
 
15
19
  // Style and theming utilities
@@ -0,0 +1,185 @@
1
+ import { getBucketInfo, getBucketResolution } from '../bucket-info';
2
+ import type { SeriesData } from '../../types';
3
+
4
+ const at = ( hoursFromStart: number ) =>
5
+ new Date( Date.UTC( 2026, 7, 1 ) + hoursFromStart * 60 * 60 * 1000 );
6
+
7
+ const seriesOf = ( offsetsInHours: number[] ): SeriesData[] => [
8
+ {
9
+ label: 'views',
10
+ data: offsetsInHours.map( ( offset, index ) => ( { date: at( offset ), value: index } ) ),
11
+ },
12
+ ];
13
+
14
+ const seriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
15
+ seriesOf( Array.from( { length: count }, ( _, index ) => index * hours ) );
16
+
17
+ const pad = ( value: number ) => String( value ).padStart( 2, '0' );
18
+
19
+ // Built from local components so it round-trips through `parseAsLocalDate`,
20
+ // which reads a `dateString` as wall-clock time in the runtime's own zone.
21
+ const seriesOfStrings = ( offsetsInHours: number[] ): SeriesData[] => [
22
+ {
23
+ label: 'views',
24
+ data: offsetsInHours.map( ( offset, index ) => {
25
+ const date = at( offset );
26
+ return {
27
+ dateString: `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad(
28
+ date.getDate()
29
+ ) }T${ pad( date.getHours() ) }:${ pad( date.getMinutes() ) }:00`,
30
+ value: index,
31
+ };
32
+ } ),
33
+ },
34
+ ];
35
+
36
+ const stringSeriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
37
+ seriesOfStrings( Array.from( { length: count }, ( _, index ) => index * hours ) );
38
+
39
+ const hourlyDates = ( start: Date, hours: number ): Date[] =>
40
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
41
+
42
+ const dailyDates = ( start: Date, days: number ): Date[] =>
43
+ Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
44
+
45
+ const toSeries = ( dates: Date[] ): SeriesData[] => [
46
+ {
47
+ label: 'series',
48
+ data: dates.map( date => ( { date, value: 1 } ) ),
49
+ },
50
+ ];
51
+
52
+ describe( 'getBucketInfo', () => {
53
+ it( 'preserves a declared week in bucket and collapses it for display', () => {
54
+ const data = seriesSpacedHours( 24 * 7, 6 );
55
+
56
+ expect( getBucketInfo( data, 'week' ) ).toEqual( {
57
+ bucket: 'week',
58
+ displayResolution: 'day',
59
+ } );
60
+ } );
61
+
62
+ it( 'passes a declared resolution through unchanged otherwise', () => {
63
+ const data = seriesSpacedHours( 1, 6 );
64
+
65
+ expect( getBucketInfo( data, 'hour' ) ).toEqual( {
66
+ bucket: 'hour',
67
+ displayResolution: 'hour',
68
+ } );
69
+ } );
70
+
71
+ it( 'infers from spacing when nothing is declared', () => {
72
+ expect( getBucketInfo( seriesSpacedHours( 1, 6 ) ) ).toEqual( {
73
+ bucket: 'hour',
74
+ displayResolution: 'hour',
75
+ } );
76
+ expect( getBucketInfo( seriesSpacedHours( 24, 6 ) ) ).toEqual( {
77
+ bucket: 'day',
78
+ displayResolution: 'day',
79
+ } );
80
+ } );
81
+
82
+ it( 'infers from dateString points, which the chart parses before it draws them', () => {
83
+ expect( getBucketInfo( stringSeriesSpacedHours( 1, 6 ) ) ).toEqual( {
84
+ bucket: 'hour',
85
+ displayResolution: 'hour',
86
+ } );
87
+ expect( getBucketInfo( stringSeriesSpacedHours( 24, 6 ) ) ).toEqual( {
88
+ bucket: 'day',
89
+ displayResolution: 'day',
90
+ } );
91
+ } );
92
+
93
+ it( 'reads a series the same whichever date field carries it', () => {
94
+ const offsets = [ 0, 1, 2, 3 ];
95
+
96
+ expect( getBucketInfo( seriesOfStrings( offsets ) ) ).toEqual(
97
+ getBucketInfo( seriesOf( offsets ) )
98
+ );
99
+ } );
100
+
101
+ it( 'never infers week, only reports it when declared', () => {
102
+ expect( getBucketInfo( seriesSpacedHours( 24 * 7, 6 ) ).bucket ).toBe( 'day' );
103
+ } );
104
+
105
+ it( 'reads the same bucket whatever order the points arrive in', () => {
106
+ const offsets = [ 0, 24, 48 ];
107
+ const shuffled = [ 0, 48, 24 ];
108
+
109
+ expect( getBucketInfo( seriesOf( shuffled ) ) ).toEqual( getBucketInfo( seriesOf( offsets ) ) );
110
+ expect( getBucketInfo( seriesOf( shuffled ) ).bucket ).toBe( 'day' );
111
+ } );
112
+
113
+ it( 'ignores a repeated instant, which pads a series rather than dating it', () => {
114
+ // A shorter comparison series can repeat its last date to match length.
115
+ expect( getBucketInfo( seriesOf( [ 0, 24, 48, 48 ] ) ).bucket ).toBe( 'day' );
116
+ } );
117
+ } );
118
+
119
+ describe( 'getBucketResolution', () => {
120
+ it( 'infers hourly buckets from sub-daily spacing', () => {
121
+ expect(
122
+ getBucketResolution( toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) ) )
123
+ ).toBe( 'hour' );
124
+ } );
125
+
126
+ it( 'infers daily buckets from daily spacing', () => {
127
+ expect(
128
+ getBucketResolution( toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 5 ) ) )
129
+ ).toBe( 'day' );
130
+ } );
131
+
132
+ it( 'infers daily buckets from weekly spacing, which labels the same way', () => {
133
+ const weeklyDates = Array.from(
134
+ { length: 5 },
135
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
136
+ );
137
+
138
+ expect( getBucketResolution( toSeries( weeklyDates ) ) ).toBe( 'day' );
139
+ } );
140
+
141
+ it( 'infers monthly buckets from a shortest-month gap', () => {
142
+ expect(
143
+ getBucketResolution(
144
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] )
145
+ )
146
+ ).toBe( 'month' );
147
+ } );
148
+
149
+ it( 'infers yearly buckets from a yearly gap', () => {
150
+ expect(
151
+ getBucketResolution(
152
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] )
153
+ )
154
+ ).toBe( 'year' );
155
+ } );
156
+
157
+ it( 'keeps a daily gap across spring-forward out of the hourly bucket', () => {
158
+ const start = new Date( '2026-03-08T00:00:00' );
159
+ const dstDates = [ 0, 23, 47 ].map(
160
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
161
+ );
162
+
163
+ expect( getBucketResolution( toSeries( dstDates ) ) ).toBe( 'day' );
164
+ } );
165
+
166
+ it( 'reports daily buckets when no series has two points', () => {
167
+ // Unmeasurable spacing reads as Infinity, which must not be mistaken for a
168
+ // very coarse bucket.
169
+ expect( getBucketResolution( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ) ) ).toBe(
170
+ 'day'
171
+ );
172
+ } );
173
+
174
+ it( 'reports a declared resolution over the measured spacing', () => {
175
+ const hourly = toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) );
176
+
177
+ expect( getBucketResolution( hourly, 'month' ) ).toBe( 'month' );
178
+ } );
179
+
180
+ it( 'reports a declared weekly resolution as daily', () => {
181
+ // Weeks and days are both calendar-date buckets as far as labelling goes,
182
+ // and 'week' is not a label format of its own.
183
+ expect( getBucketResolution( toSeries( [] ), 'week' ) ).toBe( 'day' );
184
+ } );
185
+ } );
@@ -7,7 +7,6 @@ import {
7
7
  hexToRgba,
8
8
  validateHexColor,
9
9
  parseHslString,
10
- parseRgbString,
11
10
  normalizeColorToHex,
12
11
  relativeLuminance,
13
12
  prefersLightText,
@@ -547,7 +546,7 @@ describe( 'lightenHexColor', () => {
547
546
  expect( result ).toBe( '#8080ff' );
548
547
  } );
549
548
 
550
- it( 'lightens a theme color by 80%', () => {
549
+ it( 'lightens a color by 80%', () => {
551
550
  const result = lightenHexColor( '#98C8DF', 0.8 );
552
551
  // R: 152 + (255-152)*0.8 = 152 + 82.4 = 234
553
552
  // G: 200 + (255-200)*0.8 = 200 + 44 = 244
@@ -668,52 +667,6 @@ describe( 'parseHslString', () => {
668
667
  } );
669
668
  } );
670
669
 
671
- describe( 'parseRgbString', () => {
672
- describe( 'Valid RGB strings', () => {
673
- it( 'parses rgb(255, 0, 0) to #ff0000', () => {
674
- expect( parseRgbString( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
675
- } );
676
-
677
- it( 'parses rgb(0, 255, 0) to #00ff00', () => {
678
- expect( parseRgbString( 'rgb(0, 255, 0)' ) ).toBe( '#00ff00' );
679
- } );
680
-
681
- it( 'parses rgb(0, 0, 255) to #0000ff', () => {
682
- expect( parseRgbString( 'rgb(0, 0, 255)' ) ).toBe( '#0000ff' );
683
- } );
684
-
685
- it( 'parses rgb with no spaces', () => {
686
- expect( parseRgbString( 'rgb(128,128,128)' ) ).toBe( '#808080' );
687
- } );
688
-
689
- it( 'clamps values above 255', () => {
690
- expect( parseRgbString( 'rgb(300, 0, 0)' ) ).toBe( '#ff0000' );
691
- } );
692
-
693
- it( 'clamps negative values to 0', () => {
694
- expect( parseRgbString( 'rgb(-50, 0, 0)' ) ).toBe( '#000000' );
695
- } );
696
- } );
697
-
698
- describe( 'Invalid RGB strings', () => {
699
- it( 'returns null for hsl strings', () => {
700
- expect( parseRgbString( 'hsl(0, 100%, 50%)' ) ).toBeNull();
701
- } );
702
-
703
- it( 'returns null for hex colors', () => {
704
- expect( parseRgbString( '#ff0000' ) ).toBeNull();
705
- } );
706
-
707
- it( 'returns null for rgba strings', () => {
708
- expect( parseRgbString( 'rgba(255, 0, 0, 1)' ) ).toBeNull();
709
- } );
710
-
711
- it( 'returns null for empty string', () => {
712
- expect( parseRgbString( '' ) ).toBeNull();
713
- } );
714
- } );
715
- } );
716
-
717
670
  describe( 'normalizeColorToHex', () => {
718
671
  describe( 'Hex colors', () => {
719
672
  it( 'returns valid 6-digit hex as-is', () => {
@@ -0,0 +1,89 @@
1
+ import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
2
+
3
+ // `@wordpress/jest-console` registers the matcher but ships no types for it.
4
+ declare global {
5
+ // eslint-disable-next-line @typescript-eslint/no-namespace
6
+ namespace jest {
7
+ interface Matchers< R > {
8
+ toHaveWarned(): R;
9
+ }
10
+ }
11
+ }
12
+
13
+ describe( 'createDateFormatter', () => {
14
+ it( 'renders an invalid date rather than throwing', () => {
15
+ const format = createDateFormatter(
16
+ { month: 'short', day: 'numeric' },
17
+ { locale: 'de-DE', timeZone: 'Asia/Tokyo' }
18
+ );
19
+
20
+ expect( format( NaN ) ).toBe( 'Invalid Date' );
21
+ expect( format( new Date( 'nope' ) ) ).toBe( 'Invalid Date' );
22
+ } );
23
+ } );
24
+
25
+ describe( 'createZonedClock', () => {
26
+ it( 'reads calendar fields in the supplied zone', () => {
27
+ // 00:30 on Aug 3 in Tokyo.
28
+ expect( createZonedClock( 'Asia/Tokyo' )( new Date( '2026-08-02T15:30:00Z' ) ) ).toEqual( {
29
+ month: 8,
30
+ hour: 0,
31
+ } );
32
+ } );
33
+
34
+ it.each( [
35
+ [ 'with a zone', 'Asia/Tokyo' ],
36
+ [ 'without one', undefined ],
37
+ ] )( 'answers NaN for an invalid date %s', ( _label, timeZone ) => {
38
+ expect( createZonedClock( timeZone )( new Date( 'nope' ) ) ).toEqual( {
39
+ month: NaN,
40
+ hour: NaN,
41
+ } );
42
+ } );
43
+ } );
44
+
45
+ describe( 'sanitizeFormatting', () => {
46
+ it( 'keeps a usable locale and zone', () => {
47
+ const formatting = { locale: 'de-DE', timeZone: 'Asia/Tokyo' };
48
+
49
+ expect( sanitizeFormatting( formatting ) ).toEqual( formatting );
50
+ } );
51
+
52
+ it( 'keeps a UTC offset, which a WordPress site may store instead of a zone name', () => {
53
+ expect( sanitizeFormatting( { timeZone: '+05:30' } ) ).toEqual( {
54
+ locale: undefined,
55
+ timeZone: '+05:30',
56
+ } );
57
+ } );
58
+
59
+ it( 'repairs the underscored locale WordPress reports', () => {
60
+ expect( sanitizeFormatting( { locale: 'en_US', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
61
+ locale: 'en-US',
62
+ timeZone: 'Asia/Tokyo',
63
+ } );
64
+ } );
65
+
66
+ it( 'drops a locale no underscore swap can rescue, keeping the zone', () => {
67
+ expect( sanitizeFormatting( { locale: 'nope!', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
68
+ locale: undefined,
69
+ timeZone: 'Asia/Tokyo',
70
+ } );
71
+ expect( console ).toHaveWarned();
72
+ } );
73
+
74
+ it( 'drops the empty zone a site set to a manual offset reports, keeping the locale', () => {
75
+ expect( sanitizeFormatting( { locale: 'de-DE', timeZone: '' } ) ).toEqual( {
76
+ locale: 'de-DE',
77
+ timeZone: undefined,
78
+ } );
79
+ expect( console ).toHaveWarned();
80
+ } );
81
+
82
+ it( 'drops both when neither is usable', () => {
83
+ expect( sanitizeFormatting( { locale: '', timeZone: 'Mars/Olympus' } ) ).toEqual( {
84
+ locale: undefined,
85
+ timeZone: undefined,
86
+ } );
87
+ expect( console ).toHaveWarned();
88
+ } );
89
+ } );
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-auckland.mjs
3
+ */
4
+ import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
5
+
6
+ describeZonedParsing();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-santiago.mjs
3
+ */
4
+ import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
5
+
6
+ describeZonedParsing();
@@ -6,6 +6,7 @@
6
6
  *
7
7
  */
8
8
 
9
+ import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
9
10
  import { parseAsLocalDate } from '../date-parsing';
10
11
 
11
12
  describe( 'parseAsLocalDate', () => {
@@ -173,6 +174,16 @@ describe( 'parseAsLocalDate', () => {
173
174
  expect( isNaN( result.getTime() ) ).toBe( false );
174
175
  } );
175
176
 
177
+ test( 'should read an hour-only offset as the instant it names', () => {
178
+ // ISO 8601 allows a bare `±hh`, and it must not be read as a naive wall clock.
179
+ expect( parseAsLocalDate( '2025-01-15T14:30:45+05', 'Asia/Tokyo' ).toISOString() ).toBe(
180
+ parseAsLocalDate( '2025-01-15T14:30:45+05:00', 'Asia/Tokyo' ).toISOString()
181
+ );
182
+ expect( parseAsLocalDate( '2025-01-15T14:30:45+05' ).toISOString() ).toBe(
183
+ '2025-01-15T09:30:45.000Z'
184
+ );
185
+ } );
186
+
176
187
  test( 'should handle UTC with milliseconds', () => {
177
188
  const result = parseAsLocalDate( '2025-01-15T14:30:45.123Z' );
178
189
 
@@ -304,7 +315,7 @@ describe( 'parseAsLocalDate', () => {
304
315
  ];
305
316
 
306
317
  const startTime = performance.now();
307
- const results = testDates.map( parseAsLocalDate );
318
+ const results = testDates.map( dateString => parseAsLocalDate( dateString ) );
308
319
  const endTime = performance.now();
309
320
 
310
321
  // Should complete quickly (this is more of a smoke test)
@@ -316,3 +327,7 @@ describe( 'parseAsLocalDate', () => {
316
327
  } );
317
328
  } );
318
329
  } );
330
+
331
+ // The test script pins TZ=UTC, so this run is a second viewer zone for the same
332
+ // table `date-parsing.auckland.test.ts` asserts.
333
+ describeZonedParsing();