# CalendarHeatmap

A year of daily readings, as a week-by-week grid.

- Group: Data
- Import: `import { CalendarHeatmap } from '@misoto22/folio/charts'`
- Page: https://ui.misoto22.com/components/calendar-heatmap/
- Related: heatmap, calendar, sparkline

## When to reach for it

The readings are dated and the question is about the calendar. Heatmap is the grid underneath and knows nothing about dates; this is the arrangement everybody writes on top of it and nobody writes the same way twice.

## Anatomy

- **Grid** (required) — A Heatmap: one column per week, one row per weekday, drawn from the dates rather than from the order of the array. Every date is arithmetic on UTC midnights, so the same window renders identically in every timezone.
- **Window** (required) — from and to, or the earliest and latest date in values when they are left off. The grid runs back to the start of the first week and on to the end of the last, so a column is always a whole week.
- **Weekday rows** (required) — Seven row headers from weekdayLabels, always Sunday-first in the ARRAY and rotated onto the grid by weekStartsOn — so a Monday-first calendar and a translation are two separate decisions.
- **Month headers** — A name from monthLabels on the first column each month touches, blank on the rest. The blanks carry a run of zero-width spaces so the grid can still tell them apart, which is what a lookup by column NAME requires.
- **Gaps** — A day inside the window with no entry, and every day in the leading and trailing partial weeks: a dashed outline with an announced “no data”, never the palest cell on the ramp.
- **Cell reading** — What describe returns for that day, announced with the cell. Keyed by the cell rather than by the number, so two days with the same count are two different readings.

## Best practices

### Do

- Pass from and to whenever the window is a fact about the question rather than about the data. Derived from the values, a quiet January does not exist — the grid silently becomes a different window from the one beside it, and the two are then compared anyway.
- Write describe so it names the unit. The row and column a cell is announced with are a weekday and a month over a block of five weeks, so without it the reading a screen reader gets is a date and a bare number.
- Pin domain to compare two calendars. A quiet year and a busy one drawn on their own domains look identical, and the comparison the reader came for is not merely lost but inverted.

### Don’t

- Do not send a zero for a day nothing was recorded. Null is drawn as a gap and zero as the palest cell, and a page that draws them alike invites the reader to explain an outage that was a hole in collection.
- Do not hand it dates carrying a time or a zone. Every bound is parsed as a UTC midnight from YYYY-MM-DD, so a timestamp that is 23:00 somewhere lands on the day before it in the grid.
- Do not reach for it where the rows are not weekdays. An hour-by-weekday load or a confusion matrix is a Heatmap, and going through this one means inventing dates for cells that do not have any.

## Accessibility

- The grid is a real <table>, so a screen reader walks the same structure the eye reads: a header row, a header column, and a cell that announces its own reading.
- A day with no entry announces “no data” rather than a number, so a gap and a zero are different to a reader who cannot see the ramp.
- The reading is keyed by the cell, so the date is announced with the count — the row and column headers alone name a weekday and a month, not a day.

## CalendarHeatmap

A year of daily readings, as a week-by-week grid. `Heatmap` draws a grid of named rows and columns and knows nothing about dates; this is the arrangement everybody writes on top of it and nobody writes the same way twice — a column per week, a row per weekday, a month name on the first column it touches, and the days either side of the window left as gaps rather than as zeroes. The grid is built from the DATES rather than from the order of the array, so a gap in the data is a gap on the calendar. That is the failure this exists to prevent: given a bare list of counts, a missing Tuesday shifts every reading after it by one cell, and the result is a plausible picture of a year that did not happen. Every date is arithmetic on UTC midnights. A local-time calendar drawn in two timezones is two different calendars, and the disagreement is exactly one day wide — small enough to survive review and large enough to move a reading into the wrong week.

### Props

- `title` (required) — `string`. What the calendar shows. Required, and it names the table.
- `showTitle` — `boolean` default `false`. Prints the title above the grid instead of hiding it from sight.
- `description` — `ReactNode`. A line under the title — the unit, the window, the caveat.
- `values` (required) — `CalendarHeatmapValue[]`. One entry per day that has a reading. Order does not matter; the grid is built from the dates. A day inside the window with no entry is drawn as a gap rather than as a zero, which is the distinction a calendar most often loses: "nothing happened" and "nothing was recorded" are different readings, and a page that draws them alike invites the reader to explain an outage that was a gap in collection.
- `from` — `string`. The first day the grid covers, as `YYYY-MM-DD`. Defaults to the earliest date in `values`. Give both ends whenever the window is a fact about the QUESTION rather than about the data — "the last year", "this quarter". Derived from the values, a quiet January simply does not exist, and the grid silently becomes a different window than the one beside it.
- `to` — `string`. The last day the grid covers, as `YYYY-MM-DD`. Defaults to the latest date in `values`.
- `describe` — `(value: number, date: string) => string`. What each cell announces, given its reading and its date. The default is the date and the number. Reach for this to name the unit — "3 commits on 2026-01-05" — because the row and column headers a cell is announced with are a weekday and a month over a block of five weeks, and neither says which day it was.
- `weekdayLabels` — `string[]` default `WEEKDAYS`. The seven row headers, Sunday first, whatever `weekStartsOn` is. Sunday first in the ARRAY and not on the grid: the labels are a translation and the first column is a calendar convention, and tying the two together would make a Monday-first German calendar a different array from a Monday-first English one.
- `monthLabels` — `string[]` default `MONTHS`. The twelve column headers, January first.
- `weekStartsOn` — `0 | 1 | 2 | 3 | 4 | 5 | 6` default `0`. Which weekday the grid's first row is. `0` is Sunday, `1` Monday.
- `domain` — `[number, number]`. The domain, as `[min, max]`. Derived from the readings when omitted. Pin it whenever two calendars are meant to be compared: a quiet year and a busy one drawn on their own domains look identical, and the comparison the reader came for is not merely lost but inverted.
- `className` — `string`.
- `empty` — `ChartEmptyProps`. What the grid shows when there is no window to draw.

## Example — default

```tsx
import { CalendarHeatmap, type CalendarHeatmapValue } from '@misoto22/folio/charts'

<CalendarHeatmap
  title="Commits per day"
  showTitle
  description="Darker is busier"
  values={values}
  from={FROM}
  to="2026-07-03"
  describe={(value, date) => `${String(value)} commits on ${date}`}
/>
```

## Example — a monday first week

```tsx
import { CalendarHeatmap, type CalendarHeatmapValue } from '@misoto22/folio/charts'

<CalendarHeatmap
  title="Beiträge pro Tag"
  showTitle
  values={values}
  from={FROM}
  to="2026-04-05"
  weekStartsOn={1}
  weekdayLabels={['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']}
  monthLabels={['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']}
  domain={[0, 20]}
  describe={(value, date) => `${String(value)} Beiträge am ${date}`}
/>
```
