@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -65,6 +65,7 @@
65
65
  "dependencies": {
66
66
  "@automattic/number-formatters": "^1.2.8",
67
67
  "@babel/runtime": "7.29.7",
68
+ "@date-fns/tz": "1.4.1",
68
69
  "@react-spring/web": "10.1.1",
69
70
  "@visx/annotation": "^4.0.0",
70
71
  "@visx/axis": "^4.0.0",
@@ -113,6 +113,37 @@ $focus-ring-width: var(--wpds-border-width-focus);
113
113
  background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
114
114
  }
115
115
 
116
+ // A roll-up beside the grid, on another scale from it: no fill, its figure
117
+ // always printed and emphasised.
118
+ .heatmap-chart__cell--summary {
119
+ background: none;
120
+ }
121
+
122
+ .heatmap-chart__col-label--summary {
123
+ font-weight: var(--wpds-typography-font-weight-emphasis);
124
+ }
125
+
126
+ .heatmap-chart__cell--summary .heatmap-chart__cell-value {
127
+ font-weight: var(--wpds-typography-font-weight-emphasis);
128
+ }
129
+
130
+ // Compact rows are `compactCellSize` tall, so the figure shrinks to fit them.
131
+ .heatmap-chart__grid--compact .heatmap-chart__cell--summary .heatmap-chart__cell-value {
132
+ font-size: var(--wpds-typography-font-size-xs);
133
+ line-height: 1;
134
+ }
135
+
136
+ // Deliberately on top of the grid gap: one extra gap sets a summary column
137
+ // apart from the data beside it, on cells and label alike, and the auto track
138
+ // grows by the same amount.
139
+ .heatmap-chart__gap-start {
140
+ margin-inline-start: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
141
+ }
142
+
143
+ .heatmap-chart__gap-end {
144
+ margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
145
+ }
146
+
116
147
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
117
148
  // an empty cell.
118
149
  .heatmap-chart__cell--filled {
@@ -43,6 +43,10 @@ import type { CSSProperties, FC } from 'react';
43
43
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
44
44
  const CELL_MIX_FLOOR = 0.15;
45
45
 
46
+ // One instance, not a `[]` default in the signature: `buildTooltipData` keys on
47
+ // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
48
+ const NO_ROW_LABELS: string[] = [];
49
+
46
50
  const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
47
51
  data,
48
52
  chartId: providedChartId,
@@ -55,7 +59,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
55
59
  maxCellHeight,
56
60
  minCellWidth,
57
61
  minCellHeight,
58
- rowLabels = [],
62
+ rowLabels = NO_ROW_LABELS,
59
63
  primaryColor,
60
64
  gap = 'md',
61
65
  withTooltips = false,
@@ -312,9 +316,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
312
316
  const rowTrack = compact
313
317
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
314
318
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
319
+ // A summary column takes a content-sized track: a roll-up is wider than a
320
+ // cell, and a shared track would stretch every cell to fit it. `max-content`
321
+ // as the max keeps the leftover width out of it once the data tracks hit
322
+ // `maxCellWidth`, where a plain `auto` would absorb it.
323
+ const columnTracks = data.some( column => column.summary )
324
+ ? data
325
+ .map( column => ( column.summary ? 'minmax(auto, max-content)' : columnTrack ) )
326
+ .join( ' ' )
327
+ : `repeat(${ columns }, ${ columnTrack })`;
315
328
  const gridStyle: Record< string, string | number > = {
316
329
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
317
- gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
330
+ gridTemplateColumns: `auto ${ columnTracks }`,
318
331
  gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
319
332
  };
320
333
  if ( compact ) {
@@ -322,6 +335,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
322
335
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${ compactCellSize }px`;
323
336
  }
324
337
 
338
+ // A summary column sits one gap apart from the data on either side; two
339
+ // summaries side by side share no extra gap.
340
+ const summaryGaps = ( columnIndex: number ) => {
341
+ if ( ! data[ columnIndex ]?.summary ) {
342
+ return {};
343
+ }
344
+
345
+ return {
346
+ [ styles[ 'heatmap-chart__gap-start' ] ]:
347
+ columnIndex > 0 && ! data[ columnIndex - 1 ]?.summary,
348
+ [ styles[ 'heatmap-chart__gap-end' ] ]:
349
+ columnIndex < columns - 1 && ! data[ columnIndex + 1 ]?.summary,
350
+ };
351
+ };
352
+
325
353
  const activeDescendant =
326
354
  selectedIndex !== undefined
327
355
  ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
@@ -374,7 +402,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
374
402
  { data.map( ( column, columnIndex ) => (
375
403
  <span
376
404
  key={ `col-${ columnIndex }` }
377
- className={ styles[ 'heatmap-chart__col-label' ] }
405
+ className={ clsx( styles[ 'heatmap-chart__col-label' ], {
406
+ [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
407
+ ...summaryGaps( columnIndex ),
408
+ } ) }
378
409
  >
379
410
  { column.label }
380
411
  </span>
@@ -433,7 +464,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
433
464
 
434
465
  const value = cell?.value ?? null;
435
466
  const present = isPresent( value );
436
- const normalized = present ? getNormalizedValue( value, extent ) : 0;
467
+ // A summary cell is on another scale, so it takes no fill.
468
+ const filled = present && ! column.summary;
469
+ const normalized = filled ? getNormalizedValue( value, extent ) : 0;
437
470
  const flatIndex = columnIndex * rows + rowIndex;
438
471
  const info = buildTooltipData( columnIndex, rowIndex );
439
472
  const accessibleName =
@@ -449,7 +482,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
449
482
  <div
450
483
  key={ `cell-${ columnIndex }-${ rowIndex }` }
451
484
  id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` }
452
- data-testid="heatmap-cell"
485
+ data-testid={ column.summary ? 'heatmap-cell-summary' : 'heatmap-cell' }
453
486
  role="gridcell"
454
487
  // Focus stays on the grid (aria-activedescendant); cells are
455
488
  // focusable but out of the tab order.
@@ -459,14 +492,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
459
492
  data-column={ columnIndex }
460
493
  data-row={ rowIndex }
461
494
  className={ clsx( styles[ 'heatmap-chart__cell' ], {
462
- [ styles[ 'heatmap-chart__cell--filled' ] ]: present,
495
+ [ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
463
496
  [ styles[ 'heatmap-chart__cell--strong' ] ]:
464
- present && cellHasLightText( normalized ),
497
+ filled && cellHasLightText( normalized ),
498
+ [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
499
+ ...summaryGaps( columnIndex ),
465
500
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
466
501
  selectedIndex === flatIndex,
467
502
  } ) }
468
503
  style={
469
- present
504
+ filled
470
505
  ? ( {
471
506
  '--a8c-charts-heatmap-cell-intensity': normalized,
472
507
  } as CSSProperties )
@@ -475,7 +510,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
475
510
  onMouseMove={ handleCellMouseMove }
476
511
  onMouseLeave={ handleCellMouseLeave }
477
512
  >
478
- { drawValues && present && (
513
+ { ( drawValues || column.summary ) && present && (
479
514
  <span className={ styles[ 'heatmap-chart__cell-value' ] }>
480
515
  { /* Compact display; tooltip and aria-label keep full precision. */ }
481
516
  { formatNumberCompact( value ) }
@@ -1,4 +1,5 @@
1
1
  export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
2
2
  export { buildCalendarHeatmapData } from './private';
3
- export type { CalendarHeatmapResult } from './private';
3
+ export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
4
+ export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
4
5
  export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';
@@ -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,125 +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
 
56
- // Labels stay English on the browser's calendar, unlike the time axis: this is a
57
- // plain function the host calls outside the provider, so `GlobalChartsProvider`'s
58
- // `locale` and `timeZone` cannot reach it. CHARTS-266 has the detail.
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
+ */
59
90
  export const buildCalendarHeatmapData = (
60
91
  series: DataPointDate[],
61
- options: {
62
- weekStartsOn?: 0 | 1;
63
- /**
64
- * Mark the days completing the first/last week outside the series'
65
- * date span as hidden cells (empty grid slots) instead of blank
66
- * cells, giving the calendar ragged edges. Days inside the span stay
67
- * blank cells even when the series has no entry for them.
68
- */
69
- hideOutOfRangeDays?: boolean;
70
- /**
71
- * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the
72
- * series' own. Days inside the grid but outside the series become
73
- * placeholder cells: painted as empty slots so a short series still
74
- * fills its container, but reporting nothing, since they were never
75
- * measured. A start bound is drawn from the beginning of its week, so
76
- * the grid always opens on a whole column. Bounds narrower than the
77
- * series are ignored — the grid never drops a day that carries data.
78
- */
79
- gridSpan?: { start?: string; end?: string };
80
- } = {}
92
+ options: CalendarHeatmapOptions = {}
81
93
  ): CalendarHeatmapResult => {
82
94
  const weekStartsOn = options.weekStartsOn ?? 1;
83
95
  const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
84
96
 
85
- const entries = series
86
- .map( point => ( { date: toDate( point ), value: point.value } ) )
87
- .filter( ( entry ): entry is { date: Date; value: number | null } => entry.date !== null );
88
-
89
- if ( ! entries.length ) {
90
- return { data: [], rowLabels: [] };
91
- }
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
+ }
92
122
 
93
- const valueByDay = new Map< string, number | null >();
94
- let minDate = entries[ 0 ].date;
95
- let maxDate = entries[ 0 ].date;
96
- for ( const { date, value } of entries ) {
97
- valueByDay.set( format( date, 'yyyy-MM-dd' ), value );
98
- if ( date < minDate ) {
99
- 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;
100
127
  }
101
- if ( date > maxDate ) {
102
- maxDate = date;
128
+ if ( ! maxDayKey || key > maxDayKey ) {
129
+ maxDayKey = key;
103
130
  }
104
131
  }
105
132
 
106
- // Day-key bounds for the ragged-edge option: calendar-day comparison, so
107
- // entries carrying a time of day can't shift the span.
108
- const minDayKey = format( minDate, 'yyyy-MM-dd' );
109
- const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
110
-
111
- // The grid may run wider than the series, never narrower: a bound that would
112
- // cut into the data is dropped rather than honoured.
113
- const gridMinDate = widenTo( options.gridSpan?.start, minDate, 'earlier' );
114
- const gridMaxDate = widenTo( options.gridSpan?.end, maxDate, 'later' );
133
+ if ( ! minDayKey || ! maxDayKey ) {
134
+ return { data: [], rowLabels: [] };
135
+ }
115
136
 
116
- 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
+ );
117
154
 
118
- const gridStart = startOfWeek( gridMinDate, { weekStartsOn } );
155
+ const gridStart = startOfCivilWeek( requestedMinDate, weekStartsOn );
119
156
 
120
- // A start bound rarely lands on a week start, and `gridStart` rounds it down.
121
- // The days it rounds past were no more measured than the rest of the widened
122
- // span, so they are filler too; treating them as a ragged edge instead would
123
- // notch the first column by however far the bound sat into its week.
124
- const gridMinDayKey = format( gridMinDate < minDate ? gridStart : gridMinDate, 'yyyy-MM-dd' );
125
- 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 );
126
161
 
127
162
  const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
128
- LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
163
+ LABELLED_ROWS.includes( row ) ? formatWeekday( addCivilDays( gridStart, row ) ) : ''
129
164
  );
130
165
 
131
166
  // Hide short partial first-month labels when a later month follows; compact
132
167
  // cells make adjacent labels collide. Keep the label for single-month ranges.
133
168
  const MIN_FIRST_MONTH_WEEKS = 2;
134
- const firstMonth = gridStart.getMonth();
169
+ const firstMonth = gridStart.getUTCMonth();
135
170
  let firstMonthWeeks = 0;
136
171
  while (
137
172
  firstMonthWeeks < weekCount &&
138
- addDays( gridStart, firstMonthWeeks * 7 ).getMonth() === firstMonth
173
+ addCivilDays( gridStart, firstMonthWeeks * 7 ).getUTCMonth() === firstMonth
139
174
  ) {
140
175
  firstMonthWeeks++;
141
176
  }
@@ -145,24 +180,23 @@ export const buildCalendarHeatmapData = (
145
180
  const data: HeatmapColumn[] = [];
146
181
  let previousMonth = -1;
147
182
  for ( let week = 0; week < weekCount; week++ ) {
148
- const columnStart = addDays( gridStart, week * 7 );
149
- const month = columnStart.getMonth();
183
+ const columnStart = addCivilDays( gridStart, week * 7 );
184
+ const month = columnStart.getUTCMonth();
150
185
  const isNewMonth = month !== previousMonth;
151
186
  const label =
152
- isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? format( columnStart, 'MMM' ) : '';
187
+ isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? formatMonth( columnStart ) : '';
153
188
  previousMonth = month;
154
189
 
155
190
  const cells: HeatmapCell[] = [];
156
191
  for ( let row = 0; row < 7; row++ ) {
157
- const day = addDays( gridStart, week * 7 + row );
158
- const key = format( day, 'yyyy-MM-dd' );
192
+ const day = addCivilDays( gridStart, week * 7 + row );
193
+ const key = civilKey( day );
159
194
  const cell: HeatmapCell = {
160
- label: format( day, 'EEE, MMM d, yyyy' ),
195
+ label: formatDay( day ),
161
196
  value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
162
197
  };
163
198
  if ( key < gridMinDayKey || key > gridMaxDayKey ) {
164
- // The days completing the grid's first/last week: the calendar's
165
- // ragged edge.
199
+ // The days completing the grid's first and last week: the ragged edge.
166
200
  if ( hideOutOfRangeDays ) {
167
201
  cell.hidden = true;
168
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;
@@ -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';