@automattic/charts 4.0.0 → 4.1.1

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 (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +7 -5
  4. package/dist/index.cjs +614 -259
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +30 -0
  7. package/dist/index.d.cts +88 -74
  8. package/dist/index.d.ts +88 -74
  9. package/dist/index.js +615 -261
  10. package/dist/index.js.map +1 -1
  11. package/package.json +5 -4
  12. package/src/charts/area-chart/area-chart.tsx +2 -1
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  17. package/src/charts/heatmap-chart/index.ts +10 -3
  18. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  19. package/src/charts/heatmap-chart/private/index.ts +0 -2
  20. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
  22. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  24. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  25. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  26. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  27. package/src/charts/heatmap-chart/types.ts +35 -0
  28. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  29. package/src/charts/leaderboard-chart/types.ts +2 -2
  30. package/src/charts/line-chart/line-chart.tsx +41 -7
  31. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  32. package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
  33. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  34. package/src/charts/line-chart/types.ts +13 -2
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  36. package/src/components/tooltip/accessible-tooltip.tsx +40 -12
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  38. package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  40. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
  41. package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
  42. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  44. package/src/hooks/use-chart-data-transform.ts +10 -3
  45. package/src/hooks/use-chart-margin.tsx +54 -9
  46. package/src/index.ts +4 -0
  47. package/src/styles/chart-scope.scss +11 -3
  48. package/src/styles/test/chart-scope.test.ts +35 -1
  49. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  50. package/src/types.ts +2 -2
  51. package/src/utils/date-formatting.ts +1 -18
  52. package/src/utils/date-parsing.ts +103 -52
  53. package/src/utils/get-edge-tick-widths.ts +33 -0
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  56. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  57. package/src/utils/test/date-parsing.test.ts +16 -1
  58. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  59. package/src/utils/warn-once.ts +23 -0
  60. package/src/visx/types.ts +27 -3
  61. package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-chatham.mjs
3
+ */
4
+
5
+ import { civilDate, instantDayReader, pointDayKey, startOfCivilWeek } from '../private/civil-day';
6
+
7
+ const readUtc = instantDayReader( 'UTC' );
8
+
9
+ describe( 'civilDate', () => {
10
+ test( 'rejects a day the calendar does not have', () => {
11
+ expect( civilDate( '2024-02-30' ) ).toBeNull();
12
+ expect( civilDate( '2024-13-01' ) ).toBeNull();
13
+ } );
14
+
15
+ test( 'reads a year below 1000 as itself, not as 19xx', () => {
16
+ expect( civilDate( '0099-01-05' )?.getUTCFullYear() ).toBe( 99 );
17
+ } );
18
+ } );
19
+
20
+ describe( 'instantDayReader', () => {
21
+ test( 'drops a year too wide for a four-digit key', () => {
22
+ expect( readUtc( new Date( 1e15 ) ) ).toBeNull();
23
+ } );
24
+ } );
25
+
26
+ describe( 'pointDayKey', () => {
27
+ // The same instant, written four ways ISO 8601 allows.
28
+ test.each( [ 'Z', '+00:00', '+0000', '+00' ] )( 'reads an offset written %s', offset => {
29
+ expect(
30
+ pointDayKey( { dateString: `2024-01-03T23:30:00${ offset }`, value: 1 }, readUtc )
31
+ ).toBe( '2024-01-03' );
32
+ } );
33
+
34
+ test( 'an hour-only offset re-dates the point like a full one', () => {
35
+ const readTokyo = instantDayReader( 'Asia/Tokyo' );
36
+
37
+ // 04:30 UTC, so Tokyo has already turned over to the 4th.
38
+ expect( pointDayKey( { dateString: '2024-01-03T23:30:00-05', value: 1 }, readTokyo ) ).toBe(
39
+ '2024-01-04'
40
+ );
41
+ expect( pointDayKey( { dateString: '2024-01-03T23:30:00-05:00', value: 1 }, readTokyo ) ).toBe(
42
+ '2024-01-04'
43
+ );
44
+ } );
45
+
46
+ test( 'a datetime with no offset keeps the day it was written with', () => {
47
+ expect( pointDayKey( { dateString: '2024-01-03T23:30:00', value: 1 }, readUtc ) ).toBe(
48
+ '2024-01-03'
49
+ );
50
+ } );
51
+
52
+ test( 'falls through to dateString when date carries an unreadable year', () => {
53
+ expect(
54
+ pointDayKey( { date: new Date( 1e15 ), dateString: '2024-01-03', value: 1 }, readUtc )
55
+ ).toBe( '2024-01-03' );
56
+ } );
57
+ } );
58
+
59
+ describe( 'startOfCivilWeek', () => {
60
+ test.each( [
61
+ [ 1 as const, '2024-01-01' ],
62
+ [ 0 as const, '2023-12-31' ],
63
+ ] )( 'rounds Wed Jan 3 2024 down for weekStartsOn %i', ( weekStartsOn, expected ) => {
64
+ const wednesday = civilDate( '2024-01-03' );
65
+
66
+ expect( startOfCivilWeek( wednesday!, weekStartsOn ).toISOString().slice( 0, 10 ) ).toBe(
67
+ expected
68
+ );
69
+ } );
70
+ } );
@@ -411,6 +411,19 @@ const mockRects = () =>
411
411
  } as DOMRect;
412
412
  } );
413
413
 
414
+ describe( 'HeatmapChart keyboard tooltip', () => {
415
+ test( 'opens on the selected cell without row labels', async () => {
416
+ renderChart( { withTooltips: true } );
417
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
418
+ const user = userEvent.setup();
419
+
420
+ grid.focus();
421
+ await user.keyboard( '{ArrowDown}' );
422
+
423
+ expect( within( screen.getByRole( 'tooltip' ) ).getByText( '1' ) ).toBeInTheDocument();
424
+ } );
425
+ } );
426
+
414
427
  describe( 'HeatmapChart tooltip position', () => {
415
428
  afterEach( () => {
416
429
  jest.restoreAllMocks();
@@ -432,3 +445,81 @@ describe( 'HeatmapChart tooltip position', () => {
432
445
  } );
433
446
  } );
434
447
  } );
448
+
449
+ describe( 'HeatmapChart summary column', () => {
450
+ const withTotals: HeatmapColumn[] = [
451
+ ...data,
452
+ { label: 'Total', summary: true, data: [ { value: 400 }, { value: 200 }, { value: null } ] },
453
+ ];
454
+
455
+ test( 'keeps the summary column out of the color scale', () => {
456
+ renderChart( { data: withTotals } );
457
+ // 4 is still the data maximum, so it keeps full intensity.
458
+ expect(
459
+ screen
460
+ .getByRole( 'gridcell', { name: 'W2: 4' } )
461
+ .style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' )
462
+ ).toBe( '1' );
463
+ expect(
464
+ screen
465
+ .getByRole( 'gridcell', { name: 'Total: 400' } )
466
+ .style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' )
467
+ ).toBe( '' );
468
+ } );
469
+
470
+ test( 'draws every summary cell, printing its figure even in compact mode', () => {
471
+ renderChart( { data: withTotals, compact: true } );
472
+ expect( screen.getAllByTestId( 'heatmap-cell-summary' ) ).toHaveLength( 3 );
473
+ expect( screen.getByRole( 'gridcell', { name: 'Total: 400' } ) ).toHaveTextContent( '400' );
474
+ expect( screen.getByRole( 'gridcell', { name: 'Total: No data' } ) ).toBeEmptyDOMElement();
475
+ expect( screen.getByRole( 'grid' ) ).toHaveAttribute( 'aria-colcount', '3' );
476
+ } );
477
+
478
+ test( 'sets a summary apart from the data on either side, but not from another summary', () => {
479
+ renderChart( {
480
+ data: [
481
+ { label: 'Lead', summary: true, data: [ { value: 7 }, { value: 8 }, { value: 9 } ] },
482
+ ...withTotals,
483
+ { label: 'Mean', summary: true, data: [ { value: 2 }, { value: 1 }, { value: 3 } ] },
484
+ ],
485
+ } );
486
+
487
+ const lead = screen.getByRole( 'gridcell', { name: 'Lead: 7' } );
488
+ expect( lead ).toHaveClass( 'heatmap-chart__gap-end' );
489
+ expect( lead ).not.toHaveClass( 'heatmap-chart__gap-start' );
490
+
491
+ const total = screen.getByRole( 'gridcell', { name: 'Total: 400' } );
492
+ expect( total ).toHaveClass( 'heatmap-chart__gap-start' );
493
+ expect( total ).not.toHaveClass( 'heatmap-chart__gap-end' );
494
+
495
+ const mean = screen.getByRole( 'gridcell', { name: 'Mean: 2' } );
496
+ expect( mean ).not.toHaveClass( 'heatmap-chart__gap-start' );
497
+ expect( mean ).not.toHaveClass( 'heatmap-chart__gap-end' );
498
+ expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).not.toHaveClass(
499
+ 'heatmap-chart__gap-start'
500
+ );
501
+ } );
502
+
503
+ test( 'keeps the summary track content-sized when the data tracks are capped', () => {
504
+ renderChart( { data: withTotals, maxCellWidth: 32 } );
505
+
506
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toHaveStyle( {
507
+ gridTemplateColumns: 'auto minmax(0px, 32px) minmax(0px, 32px) minmax(auto, max-content)',
508
+ } );
509
+ } );
510
+
511
+ test( 'reaches the summary column by keyboard, with its tooltip', async () => {
512
+ renderChart( { data: withTotals, withTooltips: true } );
513
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
514
+ const user = userEvent.setup();
515
+
516
+ grid.focus();
517
+ await user.keyboard( '{ArrowDown}{ArrowRight}{ArrowRight}' );
518
+
519
+ expect( grid ).toHaveAttribute(
520
+ 'aria-activedescendant',
521
+ expect.stringMatching( /-cell-2-0$/ )
522
+ );
523
+ expect( within( screen.getByRole( 'tooltip' ) ).getByText( '400' ) ).toBeInTheDocument();
524
+ } );
525
+ } );
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+
5
+ // Los Angeles runs behind UTC, the half `zoned-calendar-data` cannot cover: a label
6
+ // formatter that stops reading UTC renders the proxy on the previous day only here.
7
+ import { render } from '@testing-library/react';
8
+ import { GlobalChartsProvider } from '../../../providers';
9
+ import { useCalendarHeatmapData } from '../use-calendar-heatmap-data';
10
+ import type { DataPointDate } from '../../../types';
11
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from '../types';
12
+
13
+ // 2026-08-03 08:00 in Asia/Tokyo, 2026-08-02 16:00 in America/Los_Angeles.
14
+ const series: DataPointDate[] = [ { date: new Date( '2026-08-02T23:00:00Z' ), value: 7 } ];
15
+
16
+ // Aug 3 in UTC, Aug 2 in this file's zone, so a bucket tells the two apart.
17
+ const runtimeVsUtc: DataPointDate[] = [ { date: new Date( '2026-08-03T05:00:00Z' ), value: 7 } ];
18
+
19
+ describe( 'useCalendarHeatmapData', () => {
20
+ let result: CalendarHeatmapResult;
21
+ let renders = 0;
22
+
23
+ const Calendar = ( {
24
+ options,
25
+ points = series,
26
+ }: {
27
+ options?: CalendarHeatmapOptions;
28
+ points?: DataPointDate[];
29
+ } ) => {
30
+ result = useCalendarHeatmapData( points, { weekStartsOn: 1, ...options } );
31
+ renders++;
32
+ return null;
33
+ };
34
+
35
+ beforeEach( () => {
36
+ renders = 0;
37
+ } );
38
+
39
+ const bucketedLabels = () =>
40
+ result.data
41
+ .flatMap( column => column.data )
42
+ .filter( cell => cell.value !== null )
43
+ .map( cell => cell.label );
44
+
45
+ it( "takes the provider's locale and time zone", () => {
46
+ render(
47
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
48
+ <Calendar />
49
+ </GlobalChartsProvider>
50
+ );
51
+
52
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
53
+ expect( result.rowLabels ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
54
+ } );
55
+
56
+ it( 'lets an option win over the provider', () => {
57
+ render(
58
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
59
+ <Calendar options={ { timeZone: 'America/Los_Angeles' } } />
60
+ </GlobalChartsProvider>
61
+ );
62
+
63
+ expect( bucketedLabels() ).toEqual( [ 'So., 2. Aug. 2026' ] );
64
+ } );
65
+
66
+ it( 'reads an explicit undefined as unset rather than as an override', () => {
67
+ render(
68
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
69
+ <Calendar options={ { locale: undefined, timeZone: undefined } } />
70
+ </GlobalChartsProvider>
71
+ );
72
+
73
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
74
+ } );
75
+
76
+ it( 'falls back to the runtime with no provider around it', () => {
77
+ render( <Calendar points={ runtimeVsUtc } /> );
78
+
79
+ expect( result.rowLabels ).toEqual( [ 'Mon', '', 'Wed', '', 'Fri', '', '' ] );
80
+ // rowLabels only prove the locale fell back; the bucket proves the zone did.
81
+ expect( bucketedLabels() ).toEqual( [ 'Sun, Aug 2, 2026' ] );
82
+ } );
83
+
84
+ it( 'holds its result across a re-render that rebuilds the options object', () => {
85
+ const { rerender } = render(
86
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
87
+ <Calendar options={ { gridSpan: { start: '2026-07-06' } } } />
88
+ </GlobalChartsProvider>
89
+ );
90
+ const first = result;
91
+
92
+ rerender(
93
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
94
+ <Calendar options={ { gridSpan: { start: '2026-07-06' } } } />
95
+ </GlobalChartsProvider>
96
+ );
97
+
98
+ expect( renders ).toBeGreaterThan( 1 );
99
+ expect( result ).toBe( first );
100
+ } );
101
+
102
+ it( 'rebuilds when the provider changes zone', () => {
103
+ const { rerender } = render(
104
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
105
+ <Calendar />
106
+ </GlobalChartsProvider>
107
+ );
108
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
109
+
110
+ rerender(
111
+ <GlobalChartsProvider locale="de-DE" timeZone="America/Los_Angeles">
112
+ <Calendar />
113
+ </GlobalChartsProvider>
114
+ );
115
+
116
+ expect( bucketedLabels() ).toEqual( [ 'So., 2. Aug. 2026' ] );
117
+ } );
118
+ } );
@@ -11,6 +11,14 @@ describe( 'getValueExtent', () => {
11
11
  expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] );
12
12
  } );
13
13
 
14
+ test( 'leaves summary columns out of the extent', () => {
15
+ const withTotals: HeatmapColumn[] = [
16
+ ...data,
17
+ { label: 'Total', summary: true, data: [ { value: 500 }, { value: 900 }, { value: null } ] },
18
+ ];
19
+ expect( getValueExtent( withTotals ) ).toEqual( [ 0, 20 ] );
20
+ } );
21
+
14
22
  test( 'returns [0, 0] for all-empty data', () => {
15
23
  expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
16
24
  } );
@@ -0,0 +1,248 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-chatham.mjs
3
+ */
4
+
5
+ // Chatham runs ahead of UTC. A worker behind it renders a UTC-midnight proxy on
6
+ // its own day, so it cannot tell the grid's UTC arithmetic from local arithmetic.
7
+ import { buildCalendarHeatmapData } from '../build-calendar-data';
8
+ import type { DataPointDate } from '../../../types';
9
+
10
+ const labelsCarryingValues = ( data: ReturnType< typeof buildCalendarHeatmapData >[ 'data' ] ) =>
11
+ data.flatMap( column => column.data ).filter( cell => cell.value !== null );
12
+
13
+ // 2026-08-03 08:00 in Asia/Tokyo, 2026-08-02 16:00 in America/Los_Angeles.
14
+ const nearMidnight: DataPointDate[] = [ { date: new Date( '2026-08-02T23:00:00Z' ), value: 7 } ];
15
+
16
+ describe( 'buildCalendarHeatmapData bucketing in a host time zone', () => {
17
+ test( "buckets an instant on the host's day, not the runtime's", () => {
18
+ const tokyo = buildCalendarHeatmapData( nearMidnight, {
19
+ weekStartsOn: 1,
20
+ timeZone: 'Asia/Tokyo',
21
+ } );
22
+ const losAngeles = buildCalendarHeatmapData( nearMidnight, {
23
+ weekStartsOn: 1,
24
+ timeZone: 'America/Los_Angeles',
25
+ } );
26
+
27
+ expect( labelsCarryingValues( tokyo.data ).map( cell => cell.label ) ).toEqual( [
28
+ 'Mon, Aug 3, 2026',
29
+ ] );
30
+ expect( labelsCarryingValues( losAngeles.data ).map( cell => cell.label ) ).toEqual( [
31
+ 'Sun, Aug 2, 2026',
32
+ ] );
33
+ } );
34
+
35
+ test( 'places the grid on the host zone week, so the two zones differ by a column', () => {
36
+ // Aug 2 2026 is a Sunday and Aug 3 a Monday, so a Monday week start puts the
37
+ // two readings in different weeks entirely.
38
+ const tokyo = buildCalendarHeatmapData( nearMidnight, {
39
+ weekStartsOn: 1,
40
+ timeZone: 'Asia/Tokyo',
41
+ } );
42
+
43
+ expect( tokyo.data[ 0 ].data[ 0 ].value ).toBe( 7 );
44
+ expect( tokyo.data[ 0 ].data[ 6 ].hidden ).toBe( true );
45
+ } );
46
+
47
+ test( 'a UTC offset works where an IANA name would', () => {
48
+ const { data } = buildCalendarHeatmapData( nearMidnight, {
49
+ weekStartsOn: 1,
50
+ timeZone: '+09:00',
51
+ } );
52
+
53
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
54
+ 'Mon, Aug 3, 2026',
55
+ ] );
56
+ } );
57
+ } );
58
+
59
+ describe( 'buildCalendarHeatmapData date semantics', () => {
60
+ test( 'a bare yyyy-MM-dd is a calendar day, and no zone moves it', () => {
61
+ const series: DataPointDate[] = [ { dateString: '2024-01-03', value: 5 } ];
62
+
63
+ for ( const timeZone of [ undefined, 'Asia/Tokyo', 'America/Los_Angeles' ] ) {
64
+ const { data } = buildCalendarHeatmapData( series, { weekStartsOn: 1, timeZone } );
65
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
66
+ 'Wed, Jan 3, 2024',
67
+ ] );
68
+ }
69
+ } );
70
+
71
+ test( 'a datetime with no offset keeps the day it was written with', () => {
72
+ const series: DataPointDate[] = [ { dateString: '2024-01-03T23:30:00', value: 5 } ];
73
+ const { data } = buildCalendarHeatmapData( series, {
74
+ weekStartsOn: 1,
75
+ timeZone: 'Pacific/Kiritimati',
76
+ } );
77
+
78
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
79
+ 'Wed, Jan 3, 2024',
80
+ ] );
81
+ } );
82
+
83
+ test( 'a string carrying an offset is an instant, and the host zone re-dates it', () => {
84
+ const series: DataPointDate[] = [ { dateString: '2024-01-03T23:30:00Z', value: 5 } ];
85
+ const { data } = buildCalendarHeatmapData( series, {
86
+ weekStartsOn: 1,
87
+ timeZone: 'Asia/Tokyo',
88
+ } );
89
+
90
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
91
+ 'Thu, Jan 4, 2024',
92
+ ] );
93
+ } );
94
+
95
+ test( 'drops an instant no four-digit day key can hold', () => {
96
+ // `Date` reaches year 275760; a wider key would also sort before every other.
97
+ const outOfRange = [ { date: new Date( 1e15 ), value: 3 } ];
98
+
99
+ expect( buildCalendarHeatmapData( outOfRange ) ).toEqual( { data: [], rowLabels: [] } );
100
+ expect(
101
+ labelsCarryingValues(
102
+ buildCalendarHeatmapData( [ ...outOfRange, { dateString: '2024-01-03', value: 5 } ], {
103
+ weekStartsOn: 1,
104
+ } ).data
105
+ ).map( cell => cell.label )
106
+ ).toEqual( [ 'Wed, Jan 3, 2024' ] );
107
+ expect( console ).toHaveWarned();
108
+ } );
109
+
110
+ test( 'falls through to dateString when date is an Invalid Date', () => {
111
+ const { data } = buildCalendarHeatmapData(
112
+ [ { date: new Date( 'not a date' ), dateString: '2024-01-03', value: 5 } ],
113
+ { weekStartsOn: 1 }
114
+ );
115
+
116
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
117
+ 'Wed, Jan 3, 2024',
118
+ ] );
119
+ } );
120
+
121
+ test( 'drops a written date that names no real day', () => {
122
+ expect( buildCalendarHeatmapData( [ { dateString: '2024-02-30', value: 5 } ] ) ).toEqual( {
123
+ data: [],
124
+ rowLabels: [],
125
+ } );
126
+ expect( console ).toHaveWarned();
127
+ } );
128
+ } );
129
+
130
+ describe( 'buildCalendarHeatmapData grid bounds are compared as calendar days', () => {
131
+ // Fri Jan 5 2024, 14:00 UTC: a time of day must not make a bound look earlier than
132
+ // the series and widen the grid.
133
+ const afternoon = [ { date: new Date( '2024-01-05T14:00:00Z' ), value: 5 } ];
134
+
135
+ test( 'a bound equal to the series start does not widen the grid', () => {
136
+ const { data } = buildCalendarHeatmapData( afternoon, {
137
+ weekStartsOn: 1,
138
+ timeZone: 'UTC',
139
+ gridSpan: { start: '2024-01-05' },
140
+ } );
141
+
142
+ // Mon Jan 1 to Thu Jan 4 precede the bound: the ragged edge, not filler.
143
+ expect( data[ 0 ].data.slice( 0, 4 ).map( cell => cell.hidden ) ).toEqual( [
144
+ true,
145
+ true,
146
+ true,
147
+ true,
148
+ ] );
149
+ expect( data[ 0 ].data.slice( 0, 4 ).some( cell => cell.placeholder ) ).toBe( false );
150
+ } );
151
+
152
+ test( 'a bound genuinely earlier still widens it', () => {
153
+ const { data } = buildCalendarHeatmapData( afternoon, {
154
+ weekStartsOn: 1,
155
+ timeZone: 'UTC',
156
+ gridSpan: { start: '2024-01-04' },
157
+ } );
158
+
159
+ expect( data[ 0 ].data.slice( 0, 4 ).every( cell => cell.placeholder === true ) ).toBe( true );
160
+ } );
161
+
162
+ test( 'a bound genuinely later widens it too', () => {
163
+ const { data } = buildCalendarHeatmapData( afternoon, {
164
+ weekStartsOn: 1,
165
+ timeZone: 'UTC',
166
+ gridSpan: { end: '2024-01-12' },
167
+ } );
168
+
169
+ expect( data ).toHaveLength( 2 );
170
+ expect( data[ 1 ].data.slice( 0, 5 ).every( cell => cell.placeholder === true ) ).toBe( true );
171
+ } );
172
+
173
+ test( 'warns once and falls back on a bound that names no day', () => {
174
+ const { data } = buildCalendarHeatmapData( afternoon, {
175
+ weekStartsOn: 1,
176
+ timeZone: 'UTC',
177
+ gridSpan: { start: '2023-12' },
178
+ } );
179
+
180
+ expect( data ).toHaveLength( 1 );
181
+ expect( console ).toHaveWarned();
182
+ } );
183
+ } );
184
+
185
+ describe( 'buildCalendarHeatmapData labels in a host locale', () => {
186
+ // Three whole December weeks, so the first month keeps its label.
187
+ const series: DataPointDate[] = [
188
+ { dateString: '2023-12-11', value: 1 },
189
+ { dateString: '2024-01-03', value: 5 },
190
+ ];
191
+
192
+ test( 'writes row, month and cell labels in the locale', () => {
193
+ const { data, rowLabels } = buildCalendarHeatmapData( series, {
194
+ weekStartsOn: 1,
195
+ locale: 'de-DE',
196
+ } );
197
+
198
+ expect( rowLabels ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
199
+ expect( data.map( column => column.label ) ).toEqual( [ 'Dez', '', '', 'Jan' ] );
200
+ expect( data[ 3 ].data[ 2 ].label ).toBe( 'Mi., 3. Jan. 2024' );
201
+ } );
202
+
203
+ test( 'does not let the locale override an explicit weekStartsOn', () => {
204
+ // de-DE starts its week on Monday; the caller asked for Sunday.
205
+ const { rowLabels } = buildCalendarHeatmapData( series, {
206
+ weekStartsOn: 0,
207
+ locale: 'de-DE',
208
+ } );
209
+
210
+ expect( rowLabels ).toEqual( [ 'So', '', 'Di', '', 'Do', '', '' ] );
211
+ } );
212
+
213
+ test( 'repairs a WordPress locale and falls back rather than throwing on a bad one', () => {
214
+ expect(
215
+ buildCalendarHeatmapData( series, { weekStartsOn: 1, locale: 'de_DE' } ).rowLabels
216
+ ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
217
+
218
+ expect( () =>
219
+ buildCalendarHeatmapData( series, {
220
+ weekStartsOn: 1,
221
+ locale: 'not a tag',
222
+ timeZone: 'Nowhere/Fictional',
223
+ } )
224
+ ).not.toThrow();
225
+ expect( console ).toHaveWarned();
226
+ } );
227
+ } );
228
+
229
+ describe( 'buildCalendarHeatmapData across a DST transition', () => {
230
+ // Chile moves its clocks on 2024-09-08, at midnight rather than in the small
231
+ // hours, which is where local-midnight day arithmetic loses a day.
232
+ const daily: DataPointDate[] = Array.from( { length: 14 }, ( _, index ) => ( {
233
+ date: new Date( Date.UTC( 2024, 8, 2 + index, 16 ) ),
234
+ value: index,
235
+ } ) );
236
+
237
+ test( 'draws every day exactly once', () => {
238
+ const { data } = buildCalendarHeatmapData( daily, {
239
+ weekStartsOn: 1,
240
+ timeZone: 'America/Santiago',
241
+ } );
242
+
243
+ expect( data ).toHaveLength( 2 );
244
+ expect( labelsCarryingValues( data ) ).toHaveLength( 14 );
245
+ expect( data[ 0 ].data.map( cell => cell.value ) ).toEqual( [ 0, 1, 2, 3, 4, 5, 6 ] );
246
+ expect( data[ 1 ].data.map( cell => cell.value ) ).toEqual( [ 7, 8, 9, 10, 11, 12, 13 ] );
247
+ } );
248
+ } );
@@ -28,6 +28,12 @@ export type HeatmapColumn = {
28
28
  /** x-axis label for this column. Empty/omitted renders blank. */
29
29
  label?: string;
30
30
  data: HeatmapCell[];
31
+ /**
32
+ * A per-row roll-up such as a total or an average. Left out of the color
33
+ * scale, drawn unfilled with its figure always printed, on an `auto` track
34
+ * one gap apart from the data beside it. The chart never computes it.
35
+ */
36
+ summary?: boolean;
31
37
  };
32
38
 
33
39
  export type HeatmapTooltipData = {
@@ -76,3 +82,32 @@ export interface HeatmapChartProps
76
82
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
77
83
  children?: ReactNode;
78
84
  }
85
+
86
+ export type CalendarHeatmapResult = {
87
+ data: HeatmapColumn[];
88
+ rowLabels: string[];
89
+ };
90
+
91
+ export type CalendarHeatmapOptions = {
92
+ weekStartsOn?: 0 | 1;
93
+ /**
94
+ * Mark the days completing the first and last week outside the series' span as
95
+ * hidden cells rather than blank ones, giving the calendar ragged edges. Days
96
+ * inside the span stay blank even where the series has no entry.
97
+ */
98
+ hideOutOfRangeDays?: boolean;
99
+ /**
100
+ * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
101
+ * Days inside the grid but outside the series become placeholder cells: painted,
102
+ * but reporting nothing, since they were never measured. A start bound opens from
103
+ * the beginning of its week, and bounds narrower than the series are ignored.
104
+ */
105
+ gridSpan?: { start?: string; end?: string };
106
+ /** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
107
+ locale?: string;
108
+ /**
109
+ * IANA zone the series' instants are bucketed into days in. Defaults to the
110
+ * runtime's zone. A `dateString` carrying no offset is taken as written.
111
+ */
112
+ timeZone?: string;
113
+ };
@@ -0,0 +1,42 @@
1
+ import { useMemo } from 'react';
2
+ import { useChartFormatting } from '../../providers';
3
+ import { buildCalendarHeatmapData } from './build-calendar-data';
4
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './types';
5
+ import type { DataPointDate } from '../../types';
6
+
7
+ /**
8
+ * `buildCalendarHeatmapData` with the locale and zone taken from
9
+ * `GlobalChartsProvider` where the caller names neither.
10
+ *
11
+ * @param series - Points to bucket. Held by reference, so a caller that rebuilds it each render defeats the memo.
12
+ * @param options - As for `buildCalendarHeatmapData`; `locale` and `timeZone` win over the provider.
13
+ * @return Columns and row labels for `HeatmapChart`.
14
+ */
15
+ export const useCalendarHeatmapData = (
16
+ series: DataPointDate[],
17
+ options: CalendarHeatmapOptions = {}
18
+ ): CalendarHeatmapResult => {
19
+ const formatting = useChartFormatting();
20
+
21
+ // `??` rather than a spread, so an explicit `undefined` in `options` reads as
22
+ // "unset" and still picks the provider up.
23
+ const locale = options.locale ?? formatting.locale;
24
+ const timeZone = options.timeZone ?? formatting.timeZone;
25
+ const { weekStartsOn, hideOutOfRangeDays } = options;
26
+ const gridStart = options.gridSpan?.start;
27
+ const gridEnd = options.gridSpan?.end;
28
+
29
+ // Options are spread into primitives: the object itself is rebuilt on each render,
30
+ // even by a caller that memoized its own, and would defeat this memo.
31
+ return useMemo(
32
+ () =>
33
+ buildCalendarHeatmapData( series, {
34
+ locale,
35
+ timeZone,
36
+ weekStartsOn,
37
+ hideOutOfRangeDays,
38
+ gridSpan: { start: gridStart, end: gridEnd },
39
+ } ),
40
+ [ series, locale, timeZone, weekStartsOn, hideOutOfRangeDays, gridStart, gridEnd ]
41
+ );
42
+ };
@@ -36,9 +36,9 @@ export interface LeaderboardChartProps
36
36
  secondaryColor?: string;
37
37
 
38
38
  /**
39
- * Formatter for values
39
+ * Formatter for values. May return an element, e.g. to attach a tooltip.
40
40
  */
41
- valueFormatter?: ( value: number ) => string;
41
+ valueFormatter?: ( value: number ) => ReactNode;
42
42
 
43
43
  /**
44
44
  * Formatter for delta values