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