@automattic/charts 4.0.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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +1 -1
  4. package/dist/index.cjs +548 -199
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +21 -0
  7. package/dist/index.d.cts +86 -72
  8. package/dist/index.d.ts +86 -72
  9. package/dist/index.js +549 -201
  10. package/dist/index.js.map +1 -1
  11. package/package.json +2 -1
  12. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  13. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  14. package/src/charts/heatmap-chart/index.ts +2 -1
  15. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
  16. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  17. package/src/charts/heatmap-chart/private/index.ts +1 -1
  18. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  19. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  20. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  22. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  23. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  24. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  25. package/src/charts/heatmap-chart/types.ts +6 -0
  26. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  27. package/src/charts/leaderboard-chart/types.ts +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +39 -6
  29. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
  31. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  32. package/src/charts/line-chart/types.ts +13 -2
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  34. package/src/components/tooltip/accessible-tooltip.tsx +8 -2
  35. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  36. package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
  37. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  38. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
  39. package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
  40. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  41. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  42. package/src/hooks/use-chart-data-transform.ts +10 -3
  43. package/src/hooks/use-chart-margin.tsx +54 -9
  44. package/src/index.ts +4 -0
  45. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  46. package/src/types.ts +2 -2
  47. package/src/utils/date-formatting.ts +1 -18
  48. package/src/utils/date-parsing.ts +103 -52
  49. package/src/utils/get-edge-tick-widths.ts +33 -0
  50. package/src/utils/index.ts +1 -0
  51. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  52. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  53. package/src/utils/test/date-parsing.test.ts +16 -1
  54. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  55. package/src/utils/warn-once.ts +23 -0
  56. package/src/visx/types.ts +27 -3
@@ -9,6 +9,13 @@ jest.mock( '../../utils/get-longest-tick-width', () => ( {
9
9
  getLongestTickWidth: ( ...args: unknown[] ) => mockGetLongestTickWidth( ...args ),
10
10
  } ) );
11
11
 
12
+ // jsdom has no getComputedTextLength, so the real measurement always returns null.
13
+ const mockGetEdgeTickWidths = jest.fn();
14
+ jest.mock( '../../utils/get-edge-tick-widths', () => ( {
15
+ ...jest.requireActual( '../../utils/get-edge-tick-widths' ),
16
+ getEdgeTickWidths: ( ...args: unknown[] ) => mockGetEdgeTickWidths( ...args ),
17
+ } ) );
18
+
12
19
  describe( 'useChartMargin', () => {
13
20
  const baseTheme = {
14
21
  axisStyles: {
@@ -43,6 +50,8 @@ describe( 'useChartMargin', () => {
43
50
  beforeEach( () => {
44
51
  mockGetLongestTickWidth.mockReset();
45
52
  mockGetLongestTickWidth.mockReturnValue( 40 );
53
+ mockGetEdgeTickWidths.mockReset();
54
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 0 } );
46
55
  } );
47
56
 
48
57
  it( 'calculates left margin for left y axis', () => {
@@ -62,7 +71,7 @@ describe( 'useChartMargin', () => {
62
71
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
63
72
  expect.any( Array ),
64
73
  options.axis.y.tickFormat,
65
- theme.axisStyles.y.left.axisLabel
74
+ { fontSize: '12px' }
66
75
  );
67
76
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
68
77
  expect( result.current.left ).toBe( 51 );
@@ -85,7 +94,7 @@ describe( 'useChartMargin', () => {
85
94
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
86
95
  expect.any( Array ),
87
96
  options.axis.y.tickFormat,
88
- theme.axisStyles.y.right.axisLabel
97
+ { fontSize: '12px' }
89
98
  );
90
99
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
91
100
  expect( result.current.right ).toBe( 51 );
@@ -108,7 +117,7 @@ describe( 'useChartMargin', () => {
108
117
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
109
118
  [ 0, 1000 ],
110
119
  options.axis.y.tickFormat,
111
- theme.axisStyles.y.left.axisLabel
120
+ { fontSize: '12px' }
112
121
  );
113
122
  } );
114
123
 
@@ -185,6 +194,167 @@ describe( 'useChartMargin', () => {
185
194
  expect( result.current.bottom ).toBe( 25 );
186
195
  } );
187
196
 
197
+ describe( 'x-axis edge tick labels', () => {
198
+ const tickFormat = ( value: number ) => new Date( value ).toDateString();
199
+ const tickValues = [ 1, 2, 3 ];
200
+ const datedXOptions = ( xOverrides = {} ) => ( {
201
+ ...optionsBase,
202
+ axis: { ...optionsBase.axis, x: { tickValues, tickFormat, ...xOverrides } },
203
+ } );
204
+
205
+ it( 'reserves half of the last label on the right', () => {
206
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 60 } );
207
+
208
+ const { result } = renderHook( () =>
209
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
210
+ );
211
+
212
+ expect( result.current.right ).toBe( 30 );
213
+ } );
214
+
215
+ it( 'keeps the default right margin when the last label fits inside it', () => {
216
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 30 } );
217
+
218
+ const { result } = renderHook( () =>
219
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
220
+ );
221
+
222
+ expect( result.current.right ).toBe( 20 );
223
+ } );
224
+
225
+ it( 'widens the left margin past the y-axis reservation when the first label needs it', () => {
226
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 0 } );
227
+
228
+ const { result } = renderHook( () =>
229
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
230
+ );
231
+
232
+ // 60 for the label's overhanging half, over the 51 the y-axis ticks need.
233
+ expect( result.current.left ).toBe( 60 );
234
+ } );
235
+
236
+ it( 'measures the axis tick values with the x tick label style', () => {
237
+ const theme = {
238
+ ...baseTheme,
239
+ axisStyles: {
240
+ ...baseTheme.axisStyles,
241
+ x: {
242
+ bottom: { tickLabel: { fontSize: 11 }, tickLength: 8 } as unknown as never,
243
+ top: {} as unknown as never,
244
+ },
245
+ },
246
+ } as XYChartTheme;
247
+
248
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
249
+
250
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
251
+ fontSize: '11px',
252
+ } );
253
+ } );
254
+
255
+ it( 'falls back to the raw tick label style when its font size is a relative unit', () => {
256
+ const theme = {
257
+ ...baseTheme,
258
+ axisStyles: {
259
+ ...baseTheme.axisStyles,
260
+ x: {
261
+ bottom: { tickLabel: { fontSize: '0.875rem' }, tickLength: 8 } as unknown as never,
262
+ top: {} as unknown as never,
263
+ },
264
+ },
265
+ } as XYChartTheme;
266
+
267
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
268
+
269
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
270
+ fontSize: '0.875rem',
271
+ } );
272
+ } );
273
+
274
+ it( 'reserves the edge labels on a top x axis too', () => {
275
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
276
+
277
+ const { result } = renderHook( () =>
278
+ useChartMargin( 300, datedXOptions( { orientation: 'top' } ), data, baseTheme )
279
+ );
280
+
281
+ expect( result.current.right ).toBe( 30 );
282
+ expect( result.current.left ).toBe( 60 );
283
+ } );
284
+
285
+ it( 'reserves nothing for a hidden x axis', () => {
286
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
287
+
288
+ const { result } = renderHook( () =>
289
+ useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme )
290
+ );
291
+
292
+ expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled();
293
+ expect( result.current.right ).toBe( 20 );
294
+ expect( result.current.left ).toBe( 51 );
295
+ } );
296
+ } );
297
+
298
+ describe( 'real measurement', () => {
299
+ // Everything else here mocks the measurer out; this block runs it for real,
300
+ // so that a width that never reaches the margin would fail something.
301
+ const actual = jest.requireActual( '../../utils/get-edge-tick-widths' );
302
+ type Measurable = { getComputedTextLength?: () => number };
303
+
304
+ afterEach( () => {
305
+ delete ( window.SVGElement.prototype as Measurable ).getComputedTextLength;
306
+ } );
307
+
308
+ it( 'turns a measured edge label into a reserved margin', () => {
309
+ // jsdom ships no getComputedTextLength, so @visx/text cannot measure at all.
310
+ ( window.SVGElement.prototype as Measurable ).getComputedTextLength = function (
311
+ this: SVGElement
312
+ ) {
313
+ return ( this.textContent ?? '' ).length * 8;
314
+ };
315
+ mockGetEdgeTickWidths.mockImplementation( actual.getEdgeTickWidths );
316
+
317
+ const options = {
318
+ ...optionsBase,
319
+ axis: {
320
+ ...optionsBase.axis,
321
+ x: {
322
+ tickValues: [ 1, 2 ],
323
+ tickFormat: ( _value: number, index: number ) =>
324
+ index === 0 ? 'AA' : 'MEASURED-LAST',
325
+ },
326
+ },
327
+ };
328
+
329
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
330
+
331
+ // 'MEASURED-LAST' is 13 characters, so 104px wide, and half of it is reserved.
332
+ expect( result.current.right ).toBe( 52 );
333
+ } );
334
+ } );
335
+
336
+ describe( 'y axis gutter', () => {
337
+ it( 'measures a caller-pinned domain rather than the data range', () => {
338
+ const options = { ...optionsBase, yScale: { domain: [ 0, 1 ] as [ number, number ] } };
339
+
340
+ renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
341
+
342
+ const ticks = mockGetLongestTickWidth.mock.calls[ 0 ][ 0 ] as number[];
343
+ expect( Math.max( ...ticks ) ).toBeLessThanOrEqual( 1 );
344
+ } );
345
+
346
+ it( 'reserves no gutter for a hidden y axis', () => {
347
+ const options = {
348
+ ...optionsBase,
349
+ axis: { ...optionsBase.axis, y: { ...optionsBase.axis.y, display: false } },
350
+ };
351
+
352
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
353
+
354
+ expect( result.current.left ).toBe( 20 );
355
+ } );
356
+ } );
357
+
188
358
  describe( 'horizontal y ticks', () => {
189
359
  const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
190
360
  ...optionsBase,
@@ -1,4 +1,7 @@
1
1
  import { useMemo } from 'react';
2
+ // Imported from the module rather than the `providers` barrel: that barrel pulls
3
+ // `use-chart-registration`, which imports this file's own barrel back.
4
+ import { useChartFormatting } from '../providers/chart-context/hooks/use-chart-formatting';
2
5
  import { parseAsLocalDate } from '../utils';
3
6
  import type { SeriesData } from '../types';
4
7
 
@@ -7,7 +10,7 @@ import type { SeriesData } from '../types';
7
10
  *
8
11
  * This hook extracts the common data transformation logic used in both line-chart
9
12
  * and bar-chart components. It:
10
- * 1. Parses date strings into Date objects using parseAsLocalDate
13
+ * 1. Parses date strings into Date objects, dated in the provider's time zone
11
14
  * 2. Sorts data points by date when date properties are present
12
15
  * 3. Returns the original data unchanged when no date properties are found
13
16
  *
@@ -15,6 +18,10 @@ import type { SeriesData } from '../types';
15
18
  * @return {SeriesData[]} The transformed and sorted data
16
19
  */
17
20
  export const useChartDataTransform = ( data: SeriesData[] ) => {
21
+ // A naive `dateString` names no zone, so without this it would be read in the
22
+ // viewer's and then relabeled in the host's, and the two never cancel.
23
+ const { timeZone } = useChartFormatting();
24
+
18
25
  return useMemo( () => {
19
26
  // Check if the first data point has date or dateString properties
20
27
  const firstPoint = data?.[ 0 ]?.data?.[ 0 ];
@@ -35,7 +42,7 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
35
42
  if ( 'date' in point && point.date ) {
36
43
  date = point.date;
37
44
  } else if ( 'dateString' in point && point.dateString ) {
38
- date = parseAsLocalDate( point.dateString );
45
+ date = parseAsLocalDate( point.dateString, timeZone );
39
46
  }
40
47
 
41
48
  return {
@@ -48,5 +55,5 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
48
55
  return a.date.getTime() - b.date.getTime();
49
56
  } ),
50
57
  } ) );
51
- }, [ data ] );
58
+ }, [ data, timeZone ] );
52
59
  };
@@ -1,6 +1,6 @@
1
1
  import { createScale, getTicks } from '@visx/scale';
2
2
  import { useMemo } from 'react';
3
- import { getLongestTickWidth, resolveFontSize } from '../utils';
3
+ import { getEdgeTickWidths, getLongestTickWidth, resolveFontSize } from '../utils';
4
4
  import type { BaseChartProps, DataPointDate, SeriesData } from '../types';
5
5
  import type { XYChartTheme } from '@visx/xychart';
6
6
 
@@ -50,6 +50,33 @@ const DEFAULT_TICK_LENGTH = 8;
50
50
  */
51
51
  const DEFAULT_Y_TICK_WIDTH = 40;
52
52
 
53
+ type LabelStyle = { fontSize?: number | string; letterSpacing?: number | string };
54
+
55
+ /**
56
+ * Copy a label style with its lengths spelled out in px.
57
+ *
58
+ * `getStringWidth` applies the style through CSSOM. Blink resolves a bare number
59
+ * on an SVG `<text>`, but that is its own leniency rather than the CSS rule, and
60
+ * `buildChartTheme` hands us bare numbers.
61
+ *
62
+ * @param style - Raw label style from the theme.
63
+ * @return The same style with px-qualified lengths.
64
+ */
65
+ const toMeasurableStyle = < T extends LabelStyle >( style?: T ) => {
66
+ if ( ! style ) {
67
+ return style;
68
+ }
69
+
70
+ const fontSize = resolveFontSize( style.fontSize );
71
+ const { letterSpacing } = style;
72
+
73
+ return {
74
+ ...style,
75
+ ...( fontSize === undefined ? {} : { fontSize: `${ fontSize }px` } ),
76
+ ...( typeof letterSpacing === 'number' ? { letterSpacing: `${ letterSpacing }px` } : {} ),
77
+ };
78
+ };
79
+
53
80
  const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => {
54
81
  const xAxisStyles =
55
82
  orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
@@ -61,7 +88,7 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom
61
88
 
62
89
  const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH;
63
90
 
64
- return { fontSize, tickLength };
91
+ return { fontSize, tickLength, tickLabelStyle: toMeasurableStyle( xAxisStyles?.tickLabel ) };
65
92
  };
66
93
 
67
94
  export const useChartMargin = (
@@ -88,7 +115,9 @@ export const useChartMargin = (
88
115
  const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
89
116
  const yScale = createScale( {
90
117
  ...options.yScale,
91
- domain: [ minY, maxY ],
118
+ // A pinned domain is what the axis actually renders, so measure those
119
+ // ticks; the data's range would size the gutter for narrower labels.
120
+ domain: options.yScale?.domain ?? [ minY, maxY ],
92
121
  range: [ height, 0 ],
93
122
  } );
94
123
 
@@ -111,7 +140,7 @@ export const useChartMargin = (
111
140
  const yTickWidth = getLongestTickWidth(
112
141
  yTicks,
113
142
  options.axis?.y?.tickFormat,
114
- yAxisStyles.axisLabel
143
+ toMeasurableStyle( yAxisStyles.axisLabel )
115
144
  );
116
145
  // visx's default axis theme pushes y-axis tick labels a further 0.25em
117
146
  // away from the axis (dx of -0.25em on the left, 0.25em on the right), so
@@ -125,17 +154,20 @@ export const useChartMargin = (
125
154
  ( yAxisStyles?.tickLength ?? 0 ) +
126
155
  Math.ceil( yTickLabelFontSize * 0.25 );
127
156
 
128
- if ( yAxisOrientation === 'right' ) {
129
- defaultMargin.right = yMarginValue;
130
- } else {
131
- defaultMargin.left = yMarginValue;
157
+ // A hidden y axis reserves nothing; its gutter belongs to the plot area.
158
+ if ( options.axis?.y?.display !== false ) {
159
+ if ( yAxisOrientation === 'right' ) {
160
+ defaultMargin.right = yMarginValue;
161
+ } else {
162
+ defaultMargin.left = yMarginValue;
163
+ }
132
164
  }
133
165
 
134
166
  // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
135
167
  // This mirrors Y-axis behavior where margin is based on label size and tick length,
136
168
  // but keeps the padding minimal so consumers can control container spacing themselves.
137
169
  const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
138
- const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation );
170
+ const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
139
171
  const computedXMargin = fontSize + tickLength;
140
172
 
141
173
  if ( xOrientation === 'top' ) {
@@ -145,6 +177,19 @@ export const useChartMargin = (
145
177
  defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
146
178
  }
147
179
 
180
+ // An X-axis label is centered on its tick, so the ones at either end of the
181
+ // scale hang half their width outside the plot area and clip at the SVG edge.
182
+ if ( options.axis?.x?.display !== false ) {
183
+ const { first, last } = getEdgeTickWidths(
184
+ options.axis?.x?.tickValues ?? [],
185
+ options.axis?.x?.tickFormat,
186
+ tickLabelStyle
187
+ );
188
+
189
+ defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) );
190
+ defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) );
191
+ }
192
+
148
193
  return defaultMargin;
149
194
  }, [ options, theme, yTicks ] );
150
195
  };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  HeatmapChart,
11
11
  HeatmapChartUnresponsive,
12
12
  buildCalendarHeatmapData,
13
+ useCalendarHeatmapData,
13
14
  } from './charts/heatmap-chart';
14
15
  export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
15
16
  export { LineChart, LineChartUnresponsive } from './charts/line-chart';
@@ -113,6 +114,7 @@ export type {
113
114
  HeatmapColumn,
114
115
  HeatmapCell,
115
116
  HeatmapTooltipData,
117
+ CalendarHeatmapOptions,
116
118
  CalendarHeatmapResult,
117
119
  } from './charts/heatmap-chart';
118
120
  export type { LeaderboardChartProps } from './charts/leaderboard-chart';
@@ -138,3 +140,5 @@ export {
138
140
  export * from './utils/color-utils';
139
141
  export type { MetricValueType } from './utils';
140
142
  export type { BucketInfo } from './types';
143
+
144
+ export type { CrosshairStyle } from './visx/types';
@@ -0,0 +1,90 @@
1
+ import { parseAsLocalDate } from '../utils/date-parsing';
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
+ toHaveWarnedWith( ...args: unknown[] ): R;
9
+ }
10
+ }
11
+ }
12
+
13
+ // Every case asserts an absolute instant, so running the suite from more than one
14
+ // worker zone is what proves the result does not depend on the viewer's.
15
+ const CASES: Array< [ string, string, string ] > = [
16
+ // The shape the Stats payload gives for a day bucket.
17
+ [ '2026-08-02', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
18
+ [ '2026-08-02 09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
19
+ [ '2026-08-02T09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
20
+ // Santiago springs forward at midnight, deleting this reading entirely.
21
+ [ '2026-09-06', 'America/Santiago', '2026-09-06T04:00:00.000Z' ],
22
+ // Berlin's gap runs the other way, its offset growing rather than shrinking, which is
23
+ // the shape most of Europe and North America has. Only this case tells the smaller of
24
+ // the two candidate offsets apart from the later probe.
25
+ [ '2026-03-29 02:30:00', 'Europe/Berlin', '2026-03-29T01:30:00.000Z' ],
26
+ // And Santiago falls back at midnight, so this reading happens twice.
27
+ [ '2026-04-04 23:00:00', 'America/Santiago', '2026-04-05T02:00:00.000Z' ],
28
+ // Which of the two an overlap resolves to follows the sign of the offset, so a zone
29
+ // east of UTC settles on the other one. Auckland is where this PR's own stories sit.
30
+ [ '2026-04-05 02:30:00', 'Pacific/Auckland', '2026-04-04T14:30:00.000Z' ],
31
+ // Hours past a fall-back, where the first offset probe lands on the wrong side
32
+ // of the transition and only the second one is right.
33
+ [ '2026-11-01 08:00:00', 'America/Los_Angeles', '2026-11-01T16:00:00.000Z' ],
34
+ // Nothing about this reading is remarkable in Tokyo; it is the worker zones that make it
35
+ // worth asserting, since Santiago deletes this local midnight and used to drag the
36
+ // answer an hour with it.
37
+ [ '2026-09-06', 'Asia/Tokyo', '2026-09-05T15:00:00.000Z' ],
38
+ // date-fns reads each field as one to N digits, so an unpadded string must land on the
39
+ // same instant as its padded twin. A one-digit fraction is 5ms to date-fns, not 500.
40
+ [ '2026-8-2', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
41
+ [ '2026-08-02 9:30', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
42
+ [ '2026-08-02T9:30:5.5', 'Asia/Tokyo', '2026-08-02T00:30:05.005Z' ],
43
+ ];
44
+
45
+ // A string carrying its own offset is already an instant, so the zone must not move it.
46
+ const INSTANT_CASES: Array< [ string, string ] > = [
47
+ [ '2026-08-02T00:00:00Z', '2026-08-02T00:00:00.000Z' ],
48
+ [ '2026-08-02T00:00:00+05:00', '2026-08-01T19:00:00.000Z' ],
49
+ ];
50
+
51
+ /**
52
+ * The zone-aware half of `parseAsLocalDate`, run from whatever zone the caller's
53
+ * environment pins.
54
+ */
55
+ export const describeZonedParsing = () => {
56
+ describe( 'parseAsLocalDate with a time zone', () => {
57
+ test.each( CASES )( 'reads %s in %s', ( dateString, timeZone, expected ) => {
58
+ expect( parseAsLocalDate( dateString, timeZone ).toISOString() ).toBe( expected );
59
+ } );
60
+
61
+ test.each( INSTANT_CASES )( 'leaves %s where it is', ( dateString, expected ) => {
62
+ expect( parseAsLocalDate( dateString, 'Asia/Tokyo' ).toISOString() ).toBe( expected );
63
+ } );
64
+
65
+ it( 'falls back to the runtime zone where Intl rejects the zone, and says so', () => {
66
+ const fallback = parseAsLocalDate( '2026-08-02', 'Not/AZone' );
67
+
68
+ expect( fallback.getTime() ).toBe( parseAsLocalDate( '2026-08-02' ).getTime() );
69
+ expect( console ).toHaveWarnedWith(
70
+ '[Charts] timeZone "Not/AZone" 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".'
71
+ );
72
+ } );
73
+
74
+ it( 'keeps a year below 100 out of the 1900s', () => {
75
+ expect( parseAsLocalDate( '0099-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
76
+ expect( parseAsLocalDate( '99-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
77
+ } );
78
+
79
+ it( 'reads a naive string in the runtime zone when no zone is supplied', () => {
80
+ const local = parseAsLocalDate( '2026-08-02' );
81
+
82
+ expect( [
83
+ local.getFullYear(),
84
+ local.getMonth(),
85
+ local.getDate(),
86
+ local.getHours(),
87
+ ] ).toEqual( [ 2026, 7, 2, 0 ] );
88
+ } );
89
+ } );
90
+ };
package/src/types.ts CHANGED
@@ -478,8 +478,8 @@ export type BucketInfo = {
478
478
  * `timeZone` re-dates the instants the host supplies, so a value that is really a
479
479
  * calendar day shifts under it: `new Date( '2026-08-02' )` is UTC midnight and
480
480
  * labels as Aug 1 in `America/Los_Angeles`. Supply true instants alongside a
481
- * `timeZone`, or set only `locale` for day-bucketed data. `HeatmapChart`'s
482
- * calendar labels take neither — see `buildCalendarHeatmapData`.
481
+ * `timeZone`, or set only `locale` for day-bucketed data. `buildCalendarHeatmapData`
482
+ * is the exception: it reads a bare `yyyy-MM-dd` as the day it names.
483
483
  */
484
484
  export type ChartFormatting = {
485
485
  /** BCP-47 language tag, e.g. `de-DE`. Defaults to the runtime's locale. */
@@ -1,11 +1,6 @@
1
+ import { warnOnce } from './warn-once';
1
2
  import type { ChartFormatting } from '../types';
2
3
 
3
- /**
4
- * `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
5
- * minimal `process` locally so this file type-checks as source under `jetpack:src`.
6
- */
7
- declare const process: { env: Record< string, string | undefined > };
8
-
9
4
  // One `Intl.DateTimeFormat` per locale, zone and options triple. The axis formats
10
5
  // every bucket in the domain, and rebuilding the formatter is what costs; the tick
11
6
  // formats themselves are rebuilt on every resize, so a per-closure cache is not
@@ -27,18 +22,6 @@ const getFormatter = (
27
22
  return formatter;
28
23
  };
29
24
 
30
- const warned = new Set< string >();
31
-
32
- const warnOnce = ( key: string, message: string ): void => {
33
- if ( warned.has( key ) || process.env.NODE_ENV === 'production' ) {
34
- return;
35
- }
36
-
37
- warned.add( key );
38
- // eslint-disable-next-line no-console
39
- console.warn( `[Charts] ${ message }` );
40
- };
41
-
42
25
  const isUsable = ( formatting: ChartFormatting ): boolean => {
43
26
  try {
44
27
  // Constructing is the check: `Intl` throws on a tag or zone it cannot use.