@pearpages/heatmap 0.2.0 → 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pere Pages
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![@pearpages/heatmap — a GitHub-style contribution heatmap for React](https://heatmap.pearpages.com/og.png)](https://heatmap.pearpages.com)
2
+
1
3
  [![npm version](https://img.shields.io/npm/v/@pearpages/heatmap.svg)](https://www.npmjs.com/package/@pearpages/heatmap)
2
4
  [![Build Status](https://github.com/pearpages/heatmap/actions/workflows/publish.yml/badge.svg)](https://github.com/pearpages/heatmap/actions)
3
5
  [![License](https://img.shields.io/github/license/pearpages/heatmap.svg)](LICENSE)
@@ -5,12 +7,361 @@
5
7
 
6
8
  # Heatmap
7
9
 
10
+ A GitHub-style contribution heatmap as a React component. Themeable through CSS custom
11
+ properties, localised through `Intl`, dark-mode ready, and correct in every time zone.
12
+ No runtime dependencies: 3.3 kB gzipped, plus 1.9 kB of CSS.
13
+
14
+ **[Live demo](https://heatmap.pearpages.com)**
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @pearpages/heatmap
20
+ ```
21
+
22
+ `react` and `react-dom` (`^19.1.1`) are **peer dependencies** — the package does not
23
+ bundle them. The package is **ESM only**.
24
+
8
25
  ## Getting started
9
26
 
10
- When using the library, make sure to import the CSS file from the `dist` folder:
27
+ When using the library, make sure to import the CSS file:
11
28
 
12
29
  ```tsx
13
30
  import '@pearpages/heatmap/styles.css';
14
31
  ```
15
32
 
16
33
  This ensures that all necessary styles are applied to the heatmap components.
34
+
35
+ ## Usage
36
+
37
+ ```tsx
38
+ import {
39
+ ContributionHeatmap,
40
+ groupByWeeks,
41
+ type ContributionData,
42
+ type Period,
43
+ } from '@pearpages/heatmap';
44
+ import '@pearpages/heatmap/styles.css';
45
+
46
+ const period: Period = {
47
+ start: new Date(2024, 0, 1),
48
+ end: new Date(2024, 11, 31),
49
+ };
50
+
51
+ const contribution: ContributionData[] = [
52
+ { date: '2024-01-01', count: 3, level: 2 },
53
+ { date: '2024-01-02', count: 0, level: 0 },
54
+ // ...one entry per day
55
+ ];
56
+
57
+ function MyHeatmap() {
58
+ return (
59
+ <ContributionHeatmap
60
+ data={{ contribution, period, weeks: groupByWeeks(contribution) }}
61
+ />
62
+ );
63
+ }
64
+ ```
65
+
66
+ ### The `level` contract
67
+
68
+ `level` is **caller-supplied** and is what drives the cell colour — the component does
69
+ not derive it from `count`. You decide how your counts map onto the five buckets.
70
+
71
+ For reference, these are the thresholds the bundled mock generator uses:
72
+
73
+ | count | level |
74
+ | --- | --- |
75
+ | `0` | `0` |
76
+ | `1–2` | `1` |
77
+ | `3–5` | `2` |
78
+ | `6–8` | `3` |
79
+ | `9+` | `4` |
80
+
81
+ ### Data shape
82
+
83
+ `groupByWeeks` turns a flat, day-per-entry array into the `Week[]` the component renders.
84
+ It pads out to whole weeks and fills any missing day with `{ count: 0, level: 0 }`, so
85
+ gaps in your input are safe.
86
+
87
+ Weeks start on Sunday by default, the GitHub convention. Pass `weekStartsOn` for anywhere
88
+ that doesn't — most of Europe runs Monday to Sunday:
89
+
90
+ ```tsx
91
+ groupByWeeks(contribution, { weekStartsOn: 1 }) // Monday
92
+ ```
93
+
94
+ The component takes no matching prop: it reads the first day off the weeks it is given, so
95
+ the two can never disagree.
96
+
97
+ Two period helpers are exported for the common cases:
98
+
99
+ ```tsx
100
+ import { getLastYearPeriod, getLastMonthPeriod } from '@pearpages/heatmap';
101
+ ```
102
+
103
+ Both run to today and start the day after the same date one year or one month earlier,
104
+ clamped to the end of a shorter month — from 31 March the month window is 1–31 March.
105
+
106
+ ### Dates are local calendar days
107
+
108
+ The library reads a `Period` boundary as the calendar day it falls on **in the local time
109
+ zone**, and every `YYYY-MM-DD` string in `ContributionData` names a local day too. Build
110
+ boundaries with the local constructor, `new Date(2024, 0, 1)`, or with the helpers above.
111
+ A date-only ISO string such as `new Date('2024-01-01')` parses as **UTC** midnight, which
112
+ is still 31 December anywhere west of Greenwich, so the first day of your period would
113
+ vanish for those users.
114
+
115
+ Two helpers convert between the two forms without going through UTC:
116
+
117
+ ```tsx
118
+ import { createDateString, parseDateString } from '@pearpages/heatmap';
119
+
120
+ createDateString(new Date(2024, 0, 5)); // '2024-01-05'
121
+ parseDateString('2024-01-05'); // local midnight, 5 Jan 2024
122
+ ```
123
+
124
+ To try the component out without wiring up real data:
125
+
126
+ ```tsx
127
+ import { generateMockData, getLastYearPeriod } from '@pearpages/heatmap';
128
+
129
+ const period = getLastYearPeriod();
130
+ const contribution = generateMockData({ period, isRealistic: true });
131
+ ```
132
+
133
+ `isRealistic: true` produces weekday-weighted data — quieter weekends, a summer lull in
134
+ June–August, and the occasional spike. `false` is uniform random.
135
+
136
+ ## Props
137
+
138
+ `ContributionHeatmap`:
139
+
140
+ | Prop | Type | Default | Notes |
141
+ | --- | --- | --- | --- |
142
+ | `data.contribution` | `ContributionData[]` | required | Flat, one entry per day |
143
+ | `data.period` | `Period` | required | `{ start: Date; end: Date }`, read as local calendar days. Drives the month headers and whether a tooltip reads as in-range |
144
+ | `data.weeks` | `Week[]` | required | 7-item tuples — use `groupByWeeks` |
145
+ | `className` | `string` | `''` | Appended to the root element; this is how themes and the colour scheme are applied |
146
+ | `isReverse` | `boolean` | `false` | Vertical layout: one row per week, weekdays as columns |
147
+ | `locale` | `string` | `'en-US'` | Drives day names, month names and the tooltip date through `Intl` |
148
+ | `labels` | `HeatmapLabels` | English | The few strings `Intl` cannot derive |
149
+
150
+ There is no `weekStartsOn` prop: the component reads the first day off the weeks it is
151
+ given, so it cannot disagree with them. Set it on `groupByWeeks` instead.
152
+
153
+ Each cell is a `<td>` carrying `data-count`, `data-date`, a
154
+ `contribution-heatmap__day--level-N` class, and a `title` / `aria-label` tooltip.
155
+
156
+ The exception is the padding days that fall outside your period: they keep `data-count`
157
+ and `data-date`, but carry `contribution-heatmap__day--outside` and **no** tooltip, `role`
158
+ or `tabIndex`, so they stay out of the tab order and the accessibility tree.
159
+
160
+ ## Themes
161
+
162
+ Four palettes ship with the library. Apply one through `className`:
163
+
164
+ ```tsx
165
+ <ContributionHeatmap className="contribution-heatmap--ocean" data={...} />
166
+ ```
167
+
168
+ | Theme | `className` |
169
+ | --- | --- |
170
+ | GitHub (default) | _none_ |
171
+ | Ocean | `contribution-heatmap--ocean` |
172
+ | Sunset | `contribution-heatmap--sunset` |
173
+ | Purple | `contribution-heatmap--purple` |
174
+
175
+ Themes are nothing more than overrides of the `--color-level-0` … `--color-level-4`
176
+ custom properties, so you can define your own the same way:
177
+
178
+ ```css
179
+ .contribution-heatmap--brand {
180
+ --color-level-0: #eee;
181
+ --color-level-1: #cfe8ff;
182
+ --color-level-2: #7cc0ff;
183
+ --color-level-3: #3b93f0;
184
+ --color-level-4: #0b5cad;
185
+ }
186
+ ```
187
+
188
+ ### Light and dark
189
+
190
+ By default the component follows `prefers-color-scheme`. Two more classes force it:
191
+
192
+ | | `className` |
193
+ | --- | --- |
194
+ | Follow the system | _none_ |
195
+ | Always light | `contribution-heatmap--light` |
196
+ | Always dark | `contribution-heatmap--dark` |
197
+
198
+ They combine with a colour theme, and they also set `color-scheme`, so the grid's
199
+ scrollbar matches:
200
+
201
+ ```tsx
202
+ <ContributionHeatmap
203
+ className="contribution-heatmap--light contribution-heatmap--ocean"
204
+ data={...}
205
+ />
206
+ ```
207
+
208
+ ### Typography and sizing
209
+
210
+ The same mechanism covers the font and the cell size, so there is no prop for these
211
+ either:
212
+
213
+ | Custom property | Default | What it does |
214
+ | --- | --- | --- |
215
+ | `--heatmap-font-family` | a system UI stack | the font for every label |
216
+ | `--heatmap-label-size` | `11px` | day names, month names, legend text |
217
+ | `--heatmap-day-header-font-family` | a monospace stack | the day names above the reversed layout's columns |
218
+ | `--day-size` | `12px`; `20px` minimum when reversed | the side of each square in the default layout |
219
+ | `--day-gap` | `2px` (`1px` ≤768px) | the space between squares |
220
+
221
+ ```css
222
+ .contribution-heatmap {
223
+ --heatmap-font-family: "Inter", sans-serif;
224
+ --day-size: 16px;
225
+ }
226
+ ```
227
+
228
+ The component declares its own font rather than inheriting the host page's, so it looks
229
+ the same wherever it is dropped. Override the property to blend it back in.
230
+
231
+ Squares are always square: the grid is sized by its content and scrolls horizontally when
232
+ it doesn't fit, rather than stretching to the container. The day-name column stays pinned
233
+ while the weeks scroll under it.
234
+
235
+ The card is an ordinary block, so it fills its container; the legend is aligned to the
236
+ start so it sits under the squares rather than floating in the middle of a wide card.
237
+ Wrap it in a narrower element of your own if you want it tighter. Below 480px the
238
+ reversed layout scales its squares up to fill the width, since at that size it is the
239
+ only thing in the card.
240
+
241
+ The squares keep their size on small screens rather than shrinking. A year is 53 weeks, so
242
+ the grid overflows a phone whatever size they take — it scrolls either way, and shrinking
243
+ would only cost legibility and tap area. Set `--day-size` yourself if you want a denser
244
+ grid.
245
+
246
+ With `isReverse`, the root also carries `contribution-heatmap--reverse`. That layout puts
247
+ the day names above their columns rather than beside the rows, so no column can be
248
+ narrower than its header and the squares are sized to match rather than floating in
249
+ oversized cells.
250
+
251
+ Those headers are set in a monospace face. Every day name is exactly three characters, so
252
+ a monospace one renders all seven at an identical width — in a proportional face `Fri` is
253
+ 10px narrower than `Wed`, which leaves visibly more air around it.
254
+
255
+ Because the column can be no narrower than its header, and that width depends on the
256
+ locale — `Wed` is 20px, French `mer.` is 26.5px — the reversed square **fills its cell**
257
+ rather than taking a fixed size. `--day-size` is only a floor there. That is what keeps the
258
+ squares flush in any language. Override `--heatmap-day-header-font-family` under that
259
+ class to change the face.
260
+
261
+ The grid always renders whole weeks, so the first and last week can hold days outside your
262
+ period. Those slots are left blank — no square, no tooltip, not focusable — rather than
263
+ being drawn as zero-contribution days.
264
+
265
+ ## Languages
266
+
267
+ The library ships no translations. Day and month names come from `Intl` for whatever
268
+ `locale` you pass, and the handful of strings `Intl` cannot derive are props:
269
+
270
+ ```tsx
271
+ <ContributionHeatmap
272
+ locale="ca"
273
+ labels={{
274
+ less: 'Menys',
275
+ more: 'Més',
276
+ level: (level) => `Nivell ${level}`,
277
+ noContributions: 'Cap contribució',
278
+ contributions: (n) => (n === 1 ? '1 contribució' : `${n} contribucions`),
279
+ }}
280
+ data={{ contribution, period, weeks: groupByWeeks(contribution, { weekStartsOn: 1 }) }}
281
+ />
282
+ ```
283
+
284
+ `weekStartsOn` matters as much as the strings: Catalan — like most of Europe — runs
285
+ weeks Monday to Sunday, so a translated heatmap that still starts on Sunday reads as
286
+ wrong. It defaults to `0` (Sunday, the GitHub convention), and the component picks the
287
+ order of its day labels up from the data.
288
+
289
+ ## What's new in 0.3.1
290
+
291
+ **Dates are read as local calendar days.** In any time zone other than UTC, 0.3.0 could
292
+ shift the first or last day of a period by one: in Europe the period started a day early
293
+ and the grid grew an extra padding week, and west of Greenwich the days read back a day
294
+ early and the week start flipped to Monday. Every `YYYY-MM-DD` string and every `Period`
295
+ boundary is now a local calendar day. `createDateString` now uses local components, and
296
+ the new `parseDateString` reads a string back as local midnight. See
297
+ [Dates are local calendar days](#dates-are-local-calendar-days).
298
+
299
+ **The package page links the demo,** the README leads with a picture, and a link to the
300
+ demo shows a proper card instead of a bare URL.
301
+
302
+ Nothing to migrate. If you worked around the shifted days yourself, remove the workaround.
303
+
304
+ ## Local development
305
+
306
+ ```bash
307
+ npm install # workspace root; installs demo/ too
308
+ npm run demo # the sandbox, resolving the library from src/ (HMR)
309
+ npm run build # build dist/
310
+ npm test -- --run # run the test suite once
311
+ npm run lint
312
+ npm run check:package # publint + arethetypeswrong
313
+ npm run og -w demo # regenerate the link-preview card, demo/public/og.png (build first)
314
+ ```
315
+
316
+ `demo/` is a single Vite app that serves as both the development sandbox and the site
317
+ deployed to [heatmap.pearpages.com](https://heatmap.pearpages.com). It imports the library
318
+ by its public specifier (`@pearpages/heatmap`) and resolves it two ways:
319
+
320
+ | Command | Resolves to | Use for |
321
+ | --- | --- | --- |
322
+ | `npm run demo` | `../src` — HMR, no build step | working on the component |
323
+ | `npm run demo:dist` | `../dist` through the package's `exports` map | checking what a consumer installs |
324
+
325
+ `npm run demo:dist` rebuilds the library first. Because one `App.tsx` serves both modes,
326
+ they can't drift: if they render differently, the packaging is wrong.
327
+
328
+ `npm run check:package` is the automated version of that check — `publint` and
329
+ `arethetypeswrong` validate the `exports` map, the `files` field and the `.d.ts`
330
+ resolution. It runs in `publish.yml` and in `prepublishOnly`.
331
+
332
+ ## Releasing
333
+
334
+ There are two **independent** pipelines. Knowing which one a push triggers is the whole
335
+ game:
336
+
337
+ | Trigger | Workflow | Effect |
338
+ | --- | --- | --- |
339
+ | Push to `main` | `.github/workflows/deploy.yml` | Runs the tests, builds the library + `demo/`, deploys to GitHub Pages. **Never touches npm.** |
340
+ | Push a `v*` tag | `.github/workflows/publish.yml` | Builds and publishes to npm with provenance |
341
+
342
+ So you can push to `main` freely — nothing reaches npm until a `v*` tag is pushed.
343
+
344
+ ### The happy path
345
+
346
+ ```bash
347
+ npm version minor # bumps package.json, commits, and tags vX.Y.Z
348
+ git push --follow-tags # pushes main (deploys the demo) and the tag (publishes to npm)
349
+ ```
350
+
351
+ ### Four things that are easy to get wrong
352
+
353
+ 1. **The tag name does not set the published version.** `publish.yml` just runs
354
+ `npm publish`, which reads the version out of `package.json`. Tagging `v0.3.0` while
355
+ `package.json` still says `0.2.0` fails with a 403
356
+ (`cannot publish over previously published version`). Use `npm version`, which keeps
357
+ the two in sync for you.
358
+ 2. **A plain `git push` does not push tags.** Use `git push --follow-tags`, or push the
359
+ tag explicitly with `git push origin vX.Y.Z`.
360
+ 3. **The main-branch guard skips rather than fails.** Every step in `publish.yml` is gated
361
+ on `git merge-base --is-ancestor $GITHUB_SHA origin/main`. If the tag is not on `main`,
362
+ all steps skip and the job still reports **green** — it now emits a workflow warning
363
+ saying why, but it is still a green run with nothing published.
364
+ 4. **The demo advertises the new version before npm has it.** `demo/src/App.tsx`
365
+ renders the version read from the root `package.json`, and the Pages deploy runs on
366
+ push to `main`. The site therefore shows the bumped version as soon as the bump commit
367
+ lands — before the tag exists, and before npm has anything.