@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,12 +1,48 @@
1
- import { addDays, differenceInCalendarWeeks, format, parseISO, startOfWeek } from 'date-fns';
1
+ import { createDateFormatter, sanitizeFormatting } from '../../../utils/date-formatting';
2
+ import { warnOnce } from '../../../utils/warn-once';
3
+ import {
4
+ addCivilDays,
5
+ civilDate,
6
+ civilKey,
7
+ civilWeekSpan,
8
+ instantDayReader,
9
+ pointDayKey,
10
+ startOfCivilWeek,
11
+ writtenDayKey,
12
+ } from './civil-day';
2
13
  import type { DataPointDate } from '../../../types';
3
14
  import type { HeatmapCell, HeatmapColumn } from '../types';
15
+ import type { CivilDate, DayKey } from './civil-day';
4
16
 
5
17
  export type CalendarHeatmapResult = {
6
18
  data: HeatmapColumn[];
7
19
  rowLabels: string[];
8
20
  };
9
21
 
22
+ export type CalendarHeatmapOptions = {
23
+ weekStartsOn?: 0 | 1;
24
+ /**
25
+ * Mark the days completing the first and last week outside the series' span as
26
+ * hidden cells rather than blank ones, giving the calendar ragged edges. Days
27
+ * inside the span stay blank even where the series has no entry.
28
+ */
29
+ hideOutOfRangeDays?: boolean;
30
+ /**
31
+ * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
32
+ * Days inside the grid but outside the series become placeholder cells: painted,
33
+ * but reporting nothing, since they were never measured. A start bound opens from
34
+ * the beginning of its week, and bounds narrower than the series are ignored.
35
+ */
36
+ gridSpan?: { start?: string; end?: string };
37
+ /** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
38
+ locale?: string;
39
+ /**
40
+ * IANA zone the series' instants are bucketed into days in. Defaults to the
41
+ * runtime's zone. A `dateString` carrying no offset is taken as written.
42
+ */
43
+ timeZone?: string;
44
+ };
45
+
10
46
  /** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
11
47
  const LABELLED_ROWS = [ 0, 2, 4 ];
12
48
 
@@ -17,122 +53,124 @@ const LABELLED_ROWS = [ 0, 2, 4 ];
17
53
  * @param bound - Requested bound as `yyyy-MM-dd`, if any.
18
54
  * @param fallback - The series' own bound on this side.
19
55
  * @param direction - Which way the bound is allowed to move.
20
- * @return The bound to draw to.
56
+ * @return The day key to draw to.
21
57
  */
22
58
  const widenTo = (
23
59
  bound: string | undefined,
24
- fallback: Date,
60
+ fallback: DayKey,
25
61
  direction: 'earlier' | 'later'
26
- ): Date => {
27
- if ( ! bound ) {
28
- return fallback;
29
- }
30
-
31
- const parsed = parseISO( bound );
32
- if ( isNaN( parsed.getTime() ) ) {
62
+ ): DayKey => {
63
+ const key = bound ? writtenDayKey( bound ) : null;
64
+ if ( ! key ) {
65
+ if ( bound ) {
66
+ warnOnce(
67
+ `heatmap:gridSpan:${ bound }`,
68
+ `gridSpan.${ direction === 'earlier' ? 'start' : 'end' } ${ JSON.stringify(
69
+ bound
70
+ ) } is not a \`yyyy-MM-dd\` day, so the grid is drawn over the series' own span.`
71
+ );
72
+ }
33
73
  return fallback;
34
74
  }
35
75
 
36
76
  if ( direction === 'earlier' ) {
37
- return parsed < fallback ? parsed : fallback;
77
+ return key < fallback ? key : fallback;
38
78
  }
39
79
 
40
- return parsed > fallback ? parsed : fallback;
41
- };
42
-
43
- const toDate = ( point: DataPointDate ): Date | null => {
44
- if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
45
- return point.date;
46
- }
47
- if ( point.dateString ) {
48
- const parsed = parseISO( point.dateString );
49
- if ( ! isNaN( parsed.getTime() ) ) {
50
- return parsed;
51
- }
52
- }
53
- return null;
80
+ return key > fallback ? key : fallback;
54
81
  };
55
82
 
83
+ /**
84
+ * Lay a day-bucketed series out as calendar columns.
85
+ *
86
+ * @param series - Points to bucket.
87
+ * @param options - Grid shape, plus the locale and zone to read days in.
88
+ * @return Columns and row labels for `HeatmapChart`.
89
+ */
56
90
  export const buildCalendarHeatmapData = (
57
91
  series: DataPointDate[],
58
- options: {
59
- weekStartsOn?: 0 | 1;
60
- /**
61
- * Mark the days completing the first/last week outside the series'
62
- * date span as hidden cells (empty grid slots) instead of blank
63
- * cells, giving the calendar ragged edges. Days inside the span stay
64
- * blank cells even when the series has no entry for them.
65
- */
66
- hideOutOfRangeDays?: boolean;
67
- /**
68
- * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the
69
- * series' own. Days inside the grid but outside the series become
70
- * placeholder cells: painted as empty slots so a short series still
71
- * fills its container, but reporting nothing, since they were never
72
- * measured. A start bound is drawn from the beginning of its week, so
73
- * the grid always opens on a whole column. Bounds narrower than the
74
- * series are ignored — the grid never drops a day that carries data.
75
- */
76
- gridSpan?: { start?: string; end?: string };
77
- } = {}
92
+ options: CalendarHeatmapOptions = {}
78
93
  ): CalendarHeatmapResult => {
79
94
  const weekStartsOn = options.weekStartsOn ?? 1;
80
95
  const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
81
96
 
82
- const entries = series
83
- .map( point => ( { date: toDate( point ), value: point.value } ) )
84
- .filter( ( entry ): entry is { date: Date; value: number | null } => entry.date !== null );
85
-
86
- if ( ! entries.length ) {
87
- return { data: [], rowLabels: [] };
88
- }
97
+ // The provider sanitizes what it holds, but this function is called outside it too.
98
+ const { locale, timeZone } = sanitizeFormatting( {
99
+ locale: options.locale,
100
+ timeZone: options.timeZone,
101
+ } );
102
+
103
+ const readInstant = instantDayReader( timeZone );
104
+
105
+ const valueByDay = new Map< DayKey, number | null >();
106
+ let minDayKey: DayKey | undefined;
107
+ let maxDayKey: DayKey | undefined;
108
+ for ( const point of series ) {
109
+ const key = pointDayKey( point, readInstant );
110
+ if ( ! key ) {
111
+ const offending = point.dateString ?? ( point.date && String( point.date ) );
112
+ warnOnce(
113
+ `heatmap:unreadableDate:${ offending }`,
114
+ offending
115
+ ? `${ JSON.stringify(
116
+ offending
117
+ ) } is not a day this can read, so its point is left out of the calendar. A \`dateString\` must start \`yyyy-MM-dd\`.`
118
+ : 'A point carries neither `date` nor `dateString`, so it is left out of the calendar.'
119
+ );
120
+ continue;
121
+ }
89
122
 
90
- const valueByDay = new Map< string, number | null >();
91
- let minDate = entries[ 0 ].date;
92
- let maxDate = entries[ 0 ].date;
93
- for ( const { date, value } of entries ) {
94
- valueByDay.set( format( date, 'yyyy-MM-dd' ), value );
95
- if ( date < minDate ) {
96
- minDate = date;
123
+ valueByDay.set( key, point.value );
124
+ // Day keys are fixed-width, so lexicographic order is chronological order.
125
+ if ( ! minDayKey || key < minDayKey ) {
126
+ minDayKey = key;
97
127
  }
98
- if ( date > maxDate ) {
99
- maxDate = date;
128
+ if ( ! maxDayKey || key > maxDayKey ) {
129
+ maxDayKey = key;
100
130
  }
101
131
  }
102
132
 
103
- // Day-key bounds for the ragged-edge option: calendar-day comparison, so
104
- // entries carrying a time of day can't shift the span.
105
- const minDayKey = format( minDate, 'yyyy-MM-dd' );
106
- const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
107
-
108
- // The grid may run wider than the series, never narrower: a bound that would
109
- // cut into the data is dropped rather than honoured.
110
- const gridMinDate = widenTo( options.gridSpan?.start, minDate, 'earlier' );
111
- const gridMaxDate = widenTo( options.gridSpan?.end, maxDate, 'later' );
133
+ if ( ! minDayKey || ! maxDayKey ) {
134
+ return { data: [], rowLabels: [] };
135
+ }
112
136
 
113
- const gridMaxDayKey = format( gridMaxDate, 'yyyy-MM-dd' );
137
+ const requestedMinDayKey = widenTo( options.gridSpan?.start, minDayKey, 'earlier' );
138
+ const gridMaxDayKey = widenTo( options.gridSpan?.end, maxDayKey, 'later' );
139
+
140
+ // Both are `DayKey`s, so they already round-tripped through `civilDate` once, and it
141
+ // is a pure function of the string: re-parsing them here cannot fail.
142
+ const requestedMinDate = civilDate( requestedMinDayKey ) as CivilDate;
143
+ const gridMaxDate = civilDate( gridMaxDayKey ) as CivilDate;
144
+
145
+ // The grid walks UTC proxies, so the label formatters read UTC. The host's zone
146
+ // was already spent on bucketing.
147
+ const labelFormatting = { locale, timeZone: 'UTC' };
148
+ const formatWeekday = createDateFormatter( { weekday: 'short' }, labelFormatting );
149
+ const formatMonth = createDateFormatter( { month: 'short' }, labelFormatting );
150
+ const formatDay = createDateFormatter(
151
+ { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' },
152
+ labelFormatting
153
+ );
114
154
 
115
- const gridStart = startOfWeek( gridMinDate, { weekStartsOn } );
155
+ const gridStart = startOfCivilWeek( requestedMinDate, weekStartsOn );
116
156
 
117
- // A start bound rarely lands on a week start, and `gridStart` rounds it down.
118
- // The days it rounds past were no more measured than the rest of the widened
119
- // span, so they are filler too; treating them as a ragged edge instead would
120
- // notch the first column by however far the bound sat into its week.
121
- const gridMinDayKey = format( gridMinDate < minDate ? gridStart : gridMinDate, 'yyyy-MM-dd' );
122
- const weekCount = differenceInCalendarWeeks( gridMaxDate, gridStart, { weekStartsOn } ) + 1;
157
+ // `gridStart` rounds a start bound down to its week; those days are filler like the
158
+ // rest of the widened span, not a ragged edge that would notch the first column.
159
+ const gridMinDayKey = requestedMinDayKey < minDayKey ? civilKey( gridStart ) : requestedMinDayKey;
160
+ const weekCount = civilWeekSpan( gridStart, gridMaxDate, weekStartsOn );
123
161
 
124
162
  const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
125
- LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
163
+ LABELLED_ROWS.includes( row ) ? formatWeekday( addCivilDays( gridStart, row ) ) : ''
126
164
  );
127
165
 
128
166
  // Hide short partial first-month labels when a later month follows; compact
129
167
  // cells make adjacent labels collide. Keep the label for single-month ranges.
130
168
  const MIN_FIRST_MONTH_WEEKS = 2;
131
- const firstMonth = gridStart.getMonth();
169
+ const firstMonth = gridStart.getUTCMonth();
132
170
  let firstMonthWeeks = 0;
133
171
  while (
134
172
  firstMonthWeeks < weekCount &&
135
- addDays( gridStart, firstMonthWeeks * 7 ).getMonth() === firstMonth
173
+ addCivilDays( gridStart, firstMonthWeeks * 7 ).getUTCMonth() === firstMonth
136
174
  ) {
137
175
  firstMonthWeeks++;
138
176
  }
@@ -142,24 +180,23 @@ export const buildCalendarHeatmapData = (
142
180
  const data: HeatmapColumn[] = [];
143
181
  let previousMonth = -1;
144
182
  for ( let week = 0; week < weekCount; week++ ) {
145
- const columnStart = addDays( gridStart, week * 7 );
146
- const month = columnStart.getMonth();
183
+ const columnStart = addCivilDays( gridStart, week * 7 );
184
+ const month = columnStart.getUTCMonth();
147
185
  const isNewMonth = month !== previousMonth;
148
186
  const label =
149
- isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? format( columnStart, 'MMM' ) : '';
187
+ isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? formatMonth( columnStart ) : '';
150
188
  previousMonth = month;
151
189
 
152
190
  const cells: HeatmapCell[] = [];
153
191
  for ( let row = 0; row < 7; row++ ) {
154
- const day = addDays( gridStart, week * 7 + row );
155
- const key = format( day, 'yyyy-MM-dd' );
192
+ const day = addCivilDays( gridStart, week * 7 + row );
193
+ const key = civilKey( day );
156
194
  const cell: HeatmapCell = {
157
- label: format( day, 'EEE, MMM d, yyyy' ),
195
+ label: formatDay( day ),
158
196
  value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
159
197
  };
160
198
  if ( key < gridMinDayKey || key > gridMaxDayKey ) {
161
- // The days completing the grid's first/last week: the calendar's
162
- // ragged edge.
199
+ // The days completing the grid's first and last week: the ragged edge.
163
200
  if ( hideOutOfRangeDays ) {
164
201
  cell.hidden = true;
165
202
  }
@@ -0,0 +1,174 @@
1
+ import { parseISO } from 'date-fns';
2
+ import { hasTimezone } from '../../../utils/date-parsing';
3
+ import type { DataPointDate } from '../../../types';
4
+
5
+ // Branded so a bare string or a real instant cannot pass as either. `civilDate` and
6
+ // `writtenDayKey` validate what they mint; `civilKey` trusts its `CivilDate` argument.
7
+ declare const dayKeyBrand: unique symbol;
8
+ declare const civilDateBrand: unique symbol;
9
+
10
+ /** A calendar day as `yyyy-MM-dd`; the key every cell is bucketed on. */
11
+ export type DayKey = string & { readonly [ dayKeyBrand ]: true };
12
+
13
+ /** A UTC-midnight `Date` standing for a civil day, never a real instant. */
14
+ export type CivilDate = Date & { readonly [ civilDateBrand ]: true };
15
+
16
+ const DAY_MS = 86400000;
17
+
18
+ // A machine key, pinned so a locale like `th-TH-u-ca-buddhist-nu-thai` cannot key
19
+ // cells on Buddhist years in Thai digits.
20
+ const KEY_OPTIONS: Intl.DateTimeFormatOptions = {
21
+ calendar: 'gregory',
22
+ numberingSystem: 'latn',
23
+ year: 'numeric',
24
+ month: '2-digit',
25
+ day: '2-digit',
26
+ };
27
+
28
+ const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
29
+
30
+ const pad = ( value: number, length: number ) => String( value ).padStart( length, '0' );
31
+
32
+ /**
33
+ * The day a UTC proxy stands for.
34
+ *
35
+ * @param date - UTC proxy.
36
+ * @return Its day key.
37
+ */
38
+ export const civilKey = ( date: CivilDate ): DayKey =>
39
+ `${ pad( date.getUTCFullYear(), 4 ) }-${ pad( date.getUTCMonth() + 1, 2 ) }-${ pad(
40
+ date.getUTCDate(),
41
+ 2
42
+ ) }` as DayKey;
43
+
44
+ /**
45
+ * A day key as a UTC-midnight proxy. UTC has no DST, so every step below is exactly
46
+ * `DAY_MS`.
47
+ *
48
+ * @param key - Day key.
49
+ * @return The proxy, or null when the key names no real day.
50
+ */
51
+ export const civilDate = ( key: string ): CivilDate | null => {
52
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec( key );
53
+ if ( ! match ) {
54
+ return null;
55
+ }
56
+
57
+ // Via the setter rather than `Date.UTC`, which reads a two-digit year as 19xx.
58
+ const date = new Date( 0 ) as CivilDate;
59
+ date.setUTCFullYear( Number( match[ 1 ] ), Number( match[ 2 ] ) - 1, Number( match[ 3 ] ) );
60
+ date.setUTCHours( 0, 0, 0, 0 );
61
+
62
+ // Round-trip rather than range-check each part, so Feb 30 is rejected too.
63
+ return civilKey( date ) === key ? date : null;
64
+ };
65
+
66
+ /**
67
+ * The day a written date names, ignoring any time and zone it carries.
68
+ *
69
+ * @param text - `yyyy-MM-dd`, optionally followed by a time.
70
+ * @return Its day key, or null.
71
+ */
72
+ export const writtenDayKey = ( text: string ): DayKey | null => {
73
+ const match = DATE_PREFIX.exec( text );
74
+ if ( ! match ) {
75
+ return null;
76
+ }
77
+
78
+ const key = `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`;
79
+ return civilDate( key ) ? ( key as DayKey ) : null;
80
+ };
81
+
82
+ /**
83
+ * Reads the day an instant falls on in a zone.
84
+ *
85
+ * @param timeZone - IANA zone name; absent leaves the runtime's own.
86
+ * @return Reader taking a `Date`, null for a day no key can hold.
87
+ */
88
+ export const instantDayReader = ( timeZone?: string ) => {
89
+ const formatter = new Intl.DateTimeFormat( 'en-US', { ...KEY_OPTIONS, timeZone } );
90
+
91
+ return ( date: Date ): DayKey | null => {
92
+ const parts = formatter.formatToParts( date );
93
+ const read = ( type: Intl.DateTimeFormatPartTypes ) =>
94
+ parts.find( part => part.type === type )?.value ?? '';
95
+
96
+ const key = `${ pad( Number( read( 'year' ) ), 4 ) }-${ read( 'month' ) }-${ read( 'day' ) }`;
97
+ // A `Date` reaches year 275760, past the four digits a key holds, and a wider
98
+ // key would break the lexicographic ordering the buckets are sorted on.
99
+ return civilDate( key ) ? ( key as DayKey ) : null;
100
+ };
101
+ };
102
+
103
+ /**
104
+ * The day a series point belongs to.
105
+ *
106
+ * A `Date`, or a string carrying `Z` or an offset, is an instant read in the host's
107
+ * zone. Anything else is a wall clock the host wrote, so its date part is the day.
108
+ *
109
+ * @param point - Series point.
110
+ * @param readInstant - Reader from `instantDayReader`.
111
+ * @return Its day key, or null when the point carries no usable date.
112
+ */
113
+ export const pointDayKey = (
114
+ point: DataPointDate,
115
+ readInstant: ( date: Date ) => DayKey | null
116
+ ): DayKey | null => {
117
+ // An unusable `date` falls through to `dateString`: a host that fills both fields
118
+ // has a working value in the second. A year too wide for a key is unusable too.
119
+ if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
120
+ const key = readInstant( point.date );
121
+ if ( key ) {
122
+ return key;
123
+ }
124
+ }
125
+
126
+ const text = point.dateString;
127
+ if ( ! text ) {
128
+ return null;
129
+ }
130
+
131
+ if ( ! hasTimezone( text ) ) {
132
+ return writtenDayKey( text );
133
+ }
134
+
135
+ const parsed = parseISO( text );
136
+ return isNaN( parsed.getTime() ) ? null : readInstant( parsed );
137
+ };
138
+
139
+ /**
140
+ * Steps a proxy by whole days.
141
+ *
142
+ * @param date - UTC proxy.
143
+ * @param days - Days to add; may be negative.
144
+ * @return The shifted proxy.
145
+ */
146
+ export const addCivilDays = ( date: CivilDate, days: number ): CivilDate =>
147
+ new Date( date.getTime() + days * DAY_MS ) as CivilDate;
148
+
149
+ /**
150
+ * Rounds a proxy down to its week.
151
+ *
152
+ * @param date - UTC proxy.
153
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
154
+ * @return The proxy for that week's first day.
155
+ */
156
+ export const startOfCivilWeek = ( date: CivilDate, weekStartsOn: 0 | 1 ): CivilDate => {
157
+ const daysSinceWeekStart = ( date.getUTCDay() - weekStartsOn + 7 ) % 7;
158
+ return addCivilDays( date, -daysSinceWeekStart );
159
+ };
160
+
161
+ /**
162
+ * Counts the columns a span needs.
163
+ *
164
+ * @param from - Earlier UTC proxy.
165
+ * @param to - Later UTC proxy.
166
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
167
+ * @return Columns needed to cover the span, inclusive.
168
+ */
169
+ export const civilWeekSpan = ( from: CivilDate, to: CivilDate, weekStartsOn: 0 | 1 ): number =>
170
+ Math.floor(
171
+ ( startOfCivilWeek( to, weekStartsOn ).getTime() -
172
+ startOfCivilWeek( from, weekStartsOn ).getTime() ) /
173
+ ( 7 * DAY_MS )
174
+ ) + 1;
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
15
15
 
16
16
  export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
17
17
  const context = useContext( HeatmapContext );
18
- const { legend, backgroundColor } = useGlobalChartsTheme();
18
+ const { legend } = useGlobalChartsTheme();
19
19
  if ( ! context ) {
20
20
  return null;
21
21
  }
@@ -24,7 +24,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
24
24
 
25
25
  return (
26
26
  <Stack direction="row" gap="xs" align="center">
27
- <Text variant="body-sm" style={ labelStyle }>
27
+ <Text
28
+ variant="body-sm"
29
+ className={ styles[ 'heatmap-chart__legend-label' ] }
30
+ style={ labelStyle }
31
+ >
28
32
  { lessLabel ?? __( 'Less', 'jetpack-charts' ) }
29
33
  </Text>
30
34
  <Stack direction="row" gap="xs">
@@ -38,7 +42,6 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
38
42
  style={
39
43
  {
40
44
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
41
- '--a8c-charts-color-heatmap-background': backgroundColor,
42
45
  '--a8c-charts-heatmap-cell-intensity': intensity,
43
46
  } as CSSProperties
44
47
  }
@@ -46,7 +49,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
46
49
  );
47
50
  } ) }
48
51
  </Stack>
49
- <Text variant="body-sm" style={ labelStyle }>
52
+ <Text
53
+ variant="body-sm"
54
+ className={ styles[ 'heatmap-chart__legend-label' ] }
55
+ style={ labelStyle }
56
+ >
50
57
  { moreLabel ?? __( 'More', 'jetpack-charts' ) }
51
58
  </Text>
52
59
  </Stack>
@@ -2,6 +2,6 @@ export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-col
2
2
  export { buildCalendarHeatmapData } from './build-calendar-data';
3
3
  export { HeatmapLegend } from './heatmap-legend';
4
4
  export { HeatmapContext } from './heatmap-context';
5
- export type { CalendarHeatmapResult } from './build-calendar-data';
5
+ export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './build-calendar-data';
6
6
  export type { HeatmapLegendProps } from './heatmap-legend';
7
7
  export type { HeatmapContextValue } from './heatmap-context';
@@ -4,7 +4,8 @@ export const isPresent = ( value: number | null | undefined ): value is number =
4
4
  value !== null && value !== undefined && ! isNaN( value );
5
5
 
6
6
  /**
7
- * Get the min and max values from heatmap data, ignoring null/NaN.
7
+ * Get the min and max values from heatmap data, ignoring null/NaN. Summary
8
+ * columns stay out: a roll-up on the scale would flatten every real cell.
8
9
  * @param data - The heatmap columns
9
10
  * @return Tuple of [min, max] values
10
11
  */
@@ -12,6 +13,9 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
12
13
  let min = Infinity;
13
14
  let max = -Infinity;
14
15
  for ( const column of data ) {
16
+ if ( column.summary ) {
17
+ continue;
18
+ }
15
19
  for ( const cell of column.data ) {
16
20
  if ( ! isPresent( cell.value ) ) {
17
21
  continue;
@@ -113,6 +113,7 @@ describe( 'buildCalendarHeatmapData', () => {
113
113
  expect( values ).toContain( 3 );
114
114
  expect( values ).not.toContain( 9 );
115
115
  expect( values ).not.toContain( 7 );
116
+ expect( console ).toHaveWarned();
116
117
  } );
117
118
 
118
119
  test( 'returns empty result when every entry has an invalid date', () => {
@@ -121,6 +122,7 @@ describe( 'buildCalendarHeatmapData', () => {
121
122
  { date: new Date( NaN ), value: 2 },
122
123
  ];
123
124
  expect( buildCalendarHeatmapData( allInvalid ) ).toEqual( { data: [], rowLabels: [] } );
125
+ expect( console ).toHaveWarned();
124
126
  } );
125
127
 
126
128
  test( 'duplicate days keep the last value (no aggregation)', () => {
@@ -195,6 +197,7 @@ describe( 'buildCalendarHeatmapData with a grid wider than the series', () => {
195
197
  } );
196
198
 
197
199
  expect( data ).toHaveLength( 1 );
200
+ expect( console ).toHaveWarned();
198
201
  } );
199
202
 
200
203
  test( 'separates the ragged edge from the filler', () => {
@@ -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
+ } );