@celestia-island/hikari 0.55.234 → 0.55.235

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.234",
3
+ "version": "0.55.235",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as barrel from "../index";
3
+
4
+ /**
5
+ * The public barrel guard for the calendar heatmap's exports (R3 on the
6
+ * S3-fix wave: deleting any of these lines from index.ts kept the ENTIRE
7
+ * suite green, because every other test reaches the module through the
8
+ * direct "./calendarHeatmap" path — only an external consumer would have
9
+ * noticed). Same shape as HkFaceShell.barrel.test.ts.
10
+ *
11
+ * This file deliberately does NOT mock "@celestia-island/hikari": the
12
+ * package's self-reference resolves to the very same src/index.ts module
13
+ * as "../index", so a vi.mock here would replace the barrel under test
14
+ * with the mock's own namespace.
15
+ */
16
+ describe("the public barrel", () => {
17
+ it("exports the heatmap component under both family names", () => {
18
+ const hk = barrel.HkCalendarHeatmap as unknown as { name?: string };
19
+ expect(hk, "HkCalendarHeatmap must be re-exported").toBeTruthy();
20
+ expect(hk.name).toBe("HkCalendarHeatmap");
21
+ // The H- alias rides the HCard/HkCard family convention.
22
+ expect(barrel.HCalendarHeatmap).toBe(barrel.HkCalendarHeatmap);
23
+ });
24
+
25
+ it("exports the calendar grid math and the fill ladder", () => {
26
+ for (const name of [
27
+ "bucketLevel",
28
+ "calendarGrid",
29
+ "heatmapCellFill",
30
+ "heatmapCellOpacity",
31
+ "monthMarkers",
32
+ "todayIso",
33
+ "weekdayIndex",
34
+ "shiftIso",
35
+ ] as const) {
36
+ expect(typeof barrel[name], `${name} must be re-exported`).toBe("function");
37
+ }
38
+ expect(Array.isArray(barrel.HEATMAP_LEVEL_OPACITY)).toBe(true);
39
+ expect(barrel.HEATMAP_LEVEL_OPACITY).toHaveLength(4);
40
+ expect(typeof barrel.HEATMAP_EMPTY_FILL).toBe("string");
41
+ // The fill ladder the legend and the cells share: level 0 is the
42
+ // empty wash, never the primary token.
43
+ expect(barrel.heatmapCellFill(0)).toBe(barrel.HEATMAP_EMPTY_FILL);
44
+ expect(barrel.heatmapCellFill(0)).not.toContain("primary");
45
+ expect(barrel.heatmapCellOpacity(0)).toBeUndefined();
46
+ expect(barrel.heatmapCellOpacity(4)).toBe(barrel.HEATMAP_LEVEL_OPACITY[3]);
47
+ });
48
+ });
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, reactive } from "vue";
3
3
 
4
4
  import HkCalendarHeatmap from "./HkCalendarHeatmap";
5
- import { HEATMAP_LEVEL_OPACITY, todayIso } from "./calendarHeatmap";
5
+ import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
6
6
 
7
7
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
8
8
 
@@ -16,6 +16,9 @@ function mount(node: ReturnType<typeof h>) {
16
16
  }
17
17
 
18
18
  afterEach(() => {
19
+ // A failed assertion inside a fake-clock test must not leak the frozen
20
+ // clock into the rest of the file.
21
+ vi.useRealTimers();
19
22
  for (const { app, container } of mounts.splice(0)) {
20
23
  app.unmount();
21
24
  container.remove();
@@ -35,7 +38,7 @@ const daysFrom = (start: string, count: number, value = 0) =>
35
38
  });
36
39
 
37
40
  describe("HkCalendarHeatmap", () => {
38
- it("renders the legend (less → more, five swatches) and the grid anatomy", () => {
41
+ it("renders the legend (less → more, the empty swatch + one per level) and the grid anatomy", () => {
39
42
  const c = mount(
40
43
  h(HkCalendarHeatmap, {
41
44
  days: daysFrom(CLEAN_HEAD_MON, 7),
@@ -51,18 +54,35 @@ describe("HkCalendarHeatmap", () => {
51
54
  const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
52
55
  expect(legend?.textContent).toContain("Less");
53
56
  expect(legend?.textContent).toContain("More");
57
+ // The empty swatch plus one per CONSUMING level.
54
58
  expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
55
- HEATMAP_LEVEL_OPACITY.length,
59
+ 1 + HEATMAP_LEVEL_OPACITY.length,
56
60
  );
61
+ const swatches = c.querySelectorAll("[data-legend-level]") as NodeListOf<HTMLElement>;
62
+ // The empty swatch carries NO opacity step (its wash lives in the
63
+ // fill, HEATMAP_EMPTY_FILL — happy-dom drops color-mix() values, so
64
+ // the ladder is asserted through the opacity channel it does keep).
65
+ expect(swatches[0].style.opacity).toBe("");
66
+ // The swatch levels run 0..4 in order, empty first.
67
+ expect(
68
+ Array.from(swatches, (s) => s.getAttribute("data-legend-level")),
69
+ ).toEqual(["0", "1", "2", "3", "4"]);
70
+ // Consuming levels index the table by level - 1: 1 -> [0] … 4 -> [3].
71
+ expect(swatches[1].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[0]);
72
+ expect(swatches[2].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
73
+ expect(swatches[3].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[2]);
74
+ expect(swatches[4].style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
57
75
  // One week of days, all level 0 (no consumption): plain muted cells.
58
76
  expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
59
77
  });
60
78
 
61
- it("suppresses the leading month marker when the window starts mid-month", async () => {
79
+ it("labels the next month on the column carrying its 1st, never the partial head month", async () => {
62
80
  const c = mount(
63
81
  h(HkCalendarHeatmap, {
64
- // Trailing-October tail + November: the partial October head is
65
- // unlabeled, November's Monday carries the only marker.
82
+ // Trailing-October tail + November. October's 1st is not on the
83
+ // axis, so no slot may carry October; November's 1st (Nov 1, a
84
+ // Saturday) sits in the HEAD column, which is where its marker
85
+ // goes.
66
86
  days: daysFrom(MID_MONTH_MON, 10),
67
87
  first: null,
68
88
  }),
@@ -72,8 +92,63 @@ describe("HkCalendarHeatmap", () => {
72
92
  ".hk-calendar-heatmap-markers > span",
73
93
  ) as NodeListOf<HTMLElement>;
74
94
  expect(markers.length).toBeGreaterThanOrEqual(2);
75
- expect(markers[0].textContent).toBe(""); // partial October — suppressed
76
- expect(markers[1].textContent).not.toBe(""); // November — labeled
95
+ const texts = Array.from(markers, (m) => m.textContent);
96
+ // The head month (its 1st is off-axis) is never labeled.
97
+ expect(texts.some((t) => t?.startsWith("Oct"))).toBe(false);
98
+ expect(texts[0]).not.toBe(""); // November's 1st is in this column
99
+ expect(texts[1]).toBe("");
100
+ });
101
+
102
+ it("labels a month on the column carrying its 1st, not one column late", async () => {
103
+ // Grid opens Mon Sep 29: Oct 1 (a Wednesday) is in the head column.
104
+ const c = mount(
105
+ h(HkCalendarHeatmap, {
106
+ days: Array.from({ length: 14 }, (_, i) => ({
107
+ date: shiftIso("2025-09-29", i),
108
+ value: 0,
109
+ })),
110
+ first: null,
111
+ }),
112
+ );
113
+ await nextTick();
114
+ const markers = c.querySelectorAll(
115
+ ".hk-calendar-heatmap-markers > span",
116
+ ) as NodeListOf<HTMLElement>;
117
+ expect(markers[0].textContent).not.toBe(""); // October, mid-column
118
+ expect(markers[1].textContent).toBe("");
119
+ });
120
+
121
+ it("reserves trailing room exactly when the last column carries a marker", async () => {
122
+ const padOf = async (days: { date: string; value: number }[]) => {
123
+ const c = mount(h(HkCalendarHeatmap, { days, first: null }));
124
+ await nextTick();
125
+ const markersEl = c.querySelector(
126
+ ".hk-calendar-heatmap-markers",
127
+ ) as HTMLElement;
128
+ const texts = Array.from(
129
+ markersEl.querySelectorAll(":scope > span"),
130
+ (m) => m.textContent,
131
+ );
132
+ return {
133
+ // Logical property: hikari ships RTL hosts (HkMenu reads
134
+ // documentElement.dir), so the reserved room rides inline-end
135
+ // rather than a physical side.
136
+ pad: markersEl.style.paddingInlineEnd,
137
+ lastSet: texts.length > 0 && texts[texts.length - 1] !== "",
138
+ };
139
+ };
140
+
141
+ // Positive branch: a single week opening on Dec 1 leaves the marker
142
+ // on the LAST column — its ink needs the reserved room.
143
+ const marked = await padOf(daysFrom(CLEAN_HEAD_MON, 7));
144
+ expect(marked.lastSet).toBe(true);
145
+ expect(marked.pad).not.toBe("");
146
+
147
+ // Negative branch: ending on Nov 7 (a Friday) pads the axis to
148
+ // Nov 3–9, so the November marker sits one column in, not last.
149
+ const unmarked = await padOf(daysFrom("2025-10-27", 12));
150
+ expect(unmarked.lastSet).toBe(false);
151
+ expect(unmarked.pad).toBe("");
77
152
  });
78
153
 
79
154
  it("shows the leading month marker when the grid's first column opens its month", async () => {
@@ -90,29 +165,97 @@ describe("HkCalendarHeatmap", () => {
90
165
  expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
91
166
  });
92
167
 
93
- it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
94
- // A one-day series AT today, anchored at first = today: the head
95
- // padding hatches as "before", the tail as "after" — both phases in
96
- // one grid, all deterministic against the real clock.
97
- const today = todayIso();
168
+ it("fills the cells through the shared ladder: consuming levels primary, level 0 not", () => {
169
+ // happy-dom drops color-mix() but keeps var(), so the
170
+ // empty-vs-primary distinction IS observable here — a hardcoded
171
+ // primary for level 0 (or a hardcoded fill that ignores the
172
+ // helper) turns this red (R3: the call sites had no pin).
98
173
  const c = mount(
99
174
  h(HkCalendarHeatmap, {
100
- days: [{ date: today, value: 5 }],
101
- first: today,
175
+ days: [
176
+ { date: CLEAN_HEAD_MON, value: 10 },
177
+ { date: "2025-12-02", value: 0 },
178
+ ],
179
+ first: null,
102
180
  }),
103
181
  );
104
182
  const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
105
- const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
106
- expect(gaps.length).toBeGreaterThan(0);
107
- const phases = new Set(
108
- Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
183
+ const styleOf = (level: string) =>
184
+ (grid.querySelector(`[data-level="${level}"]`) as HTMLElement).getAttribute(
185
+ "style",
186
+ ) ?? "";
187
+ expect(styleOf("4")).toContain("var(--hi-color-primary)");
188
+ expect(styleOf("0")).not.toContain("--hi-color-primary");
189
+ // The legend rides the same decision: its empty swatch is not
190
+ // primary, every consuming swatch is.
191
+ const swatches = c.querySelectorAll(
192
+ "[data-legend-level]",
193
+ ) as NodeListOf<HTMLElement>;
194
+ expect(swatches[0].getAttribute("style") ?? "").not.toContain(
195
+ "--hi-color-primary",
196
+ );
197
+ for (const level of [1, 2, 3, 4]) {
198
+ expect(swatches[level].getAttribute("style") ?? "").toContain(
199
+ "var(--hi-color-primary)",
200
+ );
201
+ }
202
+ });
203
+
204
+ it("applies the opacity ladder by level - 1, leaving level 0 without an opacity step", () => {
205
+ // The empty cell's wash lives in its FILL, never in the opacity
206
+ // table (the old table's index 0 was dead weight) — level 0 must
207
+ // carry no opacity while every consuming level maps to [level - 1].
208
+ const c = mount(
209
+ h(HkCalendarHeatmap, {
210
+ days: [
211
+ { date: CLEAN_HEAD_MON, value: 10 },
212
+ { date: "2025-12-02", value: 5 },
213
+ { date: "2025-12-03", value: 0 },
214
+ ],
215
+ first: null,
216
+ }),
109
217
  );
110
- expect(phases.has("before")).toBe(true);
111
- expect(phases.has("after")).toBe(true);
112
- for (const gap of gaps) {
113
- expect(gap.getAttribute("aria-hidden")).toBe("true");
114
- expect(gap.getAttribute("title")).toBeNull();
115
- expect(gap.style.pointerEvents).toBe("none");
218
+ const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
219
+ const cellAt = (level: string) =>
220
+ grid.querySelector(`[data-level="${level}"]`) as HTMLElement;
221
+ expect(cellAt("4").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[3]);
222
+ expect(cellAt("2").style.opacity).toBe(HEATMAP_LEVEL_OPACITY[1]);
223
+ expect(cellAt("0").style.opacity).toBe("");
224
+ });
225
+
226
+ it("renders gap cells hatched and inert (no tooltip, no level claim)", () => {
227
+ // A one-day series AT today, anchored at first = today: the head
228
+ // padding hatches as "before", the tail as "after". The clock is
229
+ // PINNED to a Wednesday: with the real clock this test carried
230
+ // both-phase expectations into a Monday (no head padding) or a
231
+ // Sunday (no tail), so it went red two days out of seven
232
+ // (R3 2026-10-30, proven with a mocked clock).
233
+ vi.useFakeTimers();
234
+ try {
235
+ vi.setSystemTime(new Date("2025-12-03T10:00:00Z")); // a Wednesday
236
+ const c = mount(
237
+ h(HkCalendarHeatmap, {
238
+ days: [{ date: "2025-12-03", value: 5 }],
239
+ first: "2025-12-03",
240
+ }),
241
+ );
242
+ const grid = c.querySelector(
243
+ '[data-testid="hk-calendar-heatmap"]',
244
+ ) as HTMLElement;
245
+ const gaps = grid.querySelectorAll("[data-gap]") as NodeListOf<HTMLElement>;
246
+ expect(gaps.length).toBeGreaterThan(0);
247
+ const phases = new Set(
248
+ Array.from(gaps, (gap) => gap.getAttribute("data-gap")),
249
+ );
250
+ expect(phases.has("before")).toBe(true);
251
+ expect(phases.has("after")).toBe(true);
252
+ for (const gap of gaps) {
253
+ expect(gap.getAttribute("aria-hidden")).toBe("true");
254
+ expect(gap.getAttribute("title")).toBeNull();
255
+ expect(gap.style.pointerEvents).toBe("none");
256
+ }
257
+ } finally {
258
+ vi.useRealTimers();
116
259
  }
117
260
  });
118
261
 
@@ -2,9 +2,9 @@ import { computed, defineComponent, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import {
5
- bucketLevel,
6
5
  calendarGrid,
7
- HEATMAP_LEVEL_OPACITY,
6
+ heatmapCellFill,
7
+ heatmapCellOpacity,
8
8
  monthMarkers,
9
9
  todayIso,
10
10
  type CalendarCell,
@@ -12,6 +12,12 @@ import {
12
12
  } from "./calendarHeatmap";
13
13
  import "./HkCalendarHeatmap.scss";
14
14
 
15
+ /** The legend's five levels, empty first — the same ladder the cells
16
+ * ride, so the swatches come from the one fill/opacity decision
17
+ * (`heatmapCellFill` / `heatmapCellOpacity`) rather than a second,
18
+ * hand-written copy of the colors. */
19
+ const LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
20
+
15
21
  /** The no-data cell (before the account's first activity / after
16
22
  * today): muted diagonal hatch over the surface — the wowsp-style
17
23
  * "not yet" cell. Rendered inert: no tooltip, no pointer events. */
@@ -36,11 +42,10 @@ function fill(template: string, vars: Record<string, string | number>): string {
36
42
  * rail, month markers, and hatched no-data gap cells that never claim
37
43
  * a fake zero.
38
44
  *
39
- * The leading month is suppressed when the window starts mid-month:
40
- * the first column shows only the tail of its month, so the marker is
41
- * rendered only when the grid's first column actually opens its month
42
- * (Monday = the 1st) — a label over a partial head would claim a
43
- * month start that is not visible.
45
+ * The leading month is never mislabeled: a month is labeled on the
46
+ * column that carries its 1st, so a window starting mid-month has no
47
+ * label over its partial head (that month's 1st is not on the axis),
48
+ * and no month lands one column late.
44
49
  *
45
50
  * Dumb by design: the caller owns the fetch lifecycle and passes the
46
51
  * settled dense series in (`days`, oldest → newest, zero-filled,
@@ -90,33 +95,35 @@ export default defineComponent({
90
95
  () => `repeat(${weeks.value}, ${props.cellSize}px)`,
91
96
  );
92
97
 
93
- // Locale-reactive (the HkDateTimePicker convention): a computed
94
- // over the context's locale so a locale switch re-renders the
95
- // calendar's Intl-formatted labels. All formatters format the UTC
96
- // midnight instant WITH an explicit UTC zone (R2: on a negative-
97
- // offset host the weekday rail shifted a full row and tooltips
98
- // landed a day off — the series' day boundary is UTC).
98
+ // Locale-reactive formatter cache (the HkDateTimePicker convention):
99
+ // ONE Intl.DateTimeFormat per shape per locale, held in a computed
100
+ // over the context's locale — not one per rendered cell, which a
101
+ // full-year grid would pay ~370 times over. All formatters format
102
+ // the UTC midnight instant WITH an explicit UTC zone (R2: on a
103
+ // negative-offset host the weekday rail shifted a full row and
104
+ // tooltips landed a day off — the series' day boundary is UTC).
99
105
  const locale = computed(() => useI18n().locale);
106
+ const formatters = computed(() => {
107
+ const loc = locale.value;
108
+ return {
109
+ weekday: new Intl.DateTimeFormat(loc, { weekday: "short", timeZone: "UTC" }),
110
+ month: new Intl.DateTimeFormat(loc, { month: "short", timeZone: "UTC" }),
111
+ date: new Intl.DateTimeFormat(loc, { dateStyle: "medium", timeZone: "UTC" }),
112
+ };
113
+ });
100
114
  const weekdayLabel = (idx: number) =>
101
- new Intl.DateTimeFormat(locale.value, {
102
- weekday: "short",
103
- timeZone: "UTC",
104
- }).format(
115
+ formatters.value.weekday.format(
105
116
  // Any Monday-first week works — take 2024-01-01 (a Monday).
106
117
  new Date(Date.UTC(2024, 0, 1 + idx)),
107
118
  );
108
119
  const monthLabel = (m: string) => {
109
120
  if (!m) return "";
110
- return new Intl.DateTimeFormat(locale.value, {
111
- month: "short",
112
- timeZone: "UTC",
113
- }).format(new Date(Date.UTC(2024, Number(m) - 1, 1)));
121
+ return formatters.value.month.format(
122
+ new Date(Date.UTC(2024, Number(m) - 1, 1)),
123
+ );
114
124
  };
115
125
  const dateLabel = (iso: string) =>
116
- new Intl.DateTimeFormat(locale.value, {
117
- dateStyle: "medium",
118
- timeZone: "UTC",
119
- }).format(new Date(`${iso}T00:00:00Z`));
126
+ formatters.value.date.format(new Date(`${iso}T00:00:00Z`));
120
127
 
121
128
  const cellTitle = (d: Extract<CalendarCell, { kind: "day" }>) =>
122
129
  fill(
@@ -135,13 +142,24 @@ export default defineComponent({
135
142
  }
136
143
  : {
137
144
  borderRadius: "3px",
138
- background:
139
- cell.level === 0
140
- ? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
141
- : "var(--hi-color-primary)",
142
- opacity: cell.level === 0 ? undefined : HEATMAP_LEVEL_OPACITY[cell.level],
145
+ background: heatmapCellFill(cell.level),
146
+ opacity: heatmapCellOpacity(cell.level),
143
147
  };
144
148
 
149
+ // A marker may overflow its 14px column into the empty columns
150
+ // ahead (the GitHub convention — a month label is always followed
151
+ // by unmarked columns). A marker on the LAST column has nothing
152
+ // ahead: its ink would be clipped by the scroll container, so that
153
+ // one case reserves inline-end room for it. The logical property
154
+ // matters: hikari ships RTL hosts (HkMenu reads
155
+ // documentElement.dir), and there the label runs the other way.
156
+ const markersTrailingPad = computed(() => {
157
+ const m = markers.value;
158
+ return m.length > 0 && m[m.length - 1] !== ""
159
+ ? `${props.cellSize * 2 + props.cellGap}px`
160
+ : undefined;
161
+ });
162
+
145
163
  return () => (
146
164
  <div class="hk-calendar-heatmap">
147
165
  {/* The legend row: the scale in the caller's one unit on the
@@ -154,19 +172,16 @@ export default defineComponent({
154
172
  data-testid="hk-calendar-heatmap-legend"
155
173
  >
156
174
  {t("hikari::calendarHeatmap.less", "Less")}
157
- {HEATMAP_LEVEL_OPACITY.map((o, i) => (
175
+ {LEGEND_LEVELS.map((level) => (
158
176
  <span
159
- key={i}
160
- data-legend-level={i}
177
+ key={level}
178
+ data-legend-level={level}
161
179
  style={{
162
180
  width: "10px",
163
181
  height: "10px",
164
182
  borderRadius: "2px",
165
- background:
166
- i === 0
167
- ? "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)"
168
- : "var(--hi-color-primary)",
169
- opacity: i === 0 ? undefined : o,
183
+ background: heatmapCellFill(level),
184
+ opacity: heatmapCellOpacity(level),
170
185
  display: "inline-block",
171
186
  }}
172
187
  />
@@ -199,14 +214,16 @@ export default defineComponent({
199
214
  </div>
200
215
 
201
216
  <div class="hk-calendar-heatmap-cells">
202
- {/* Month markers, one slot per week column. A leading
203
- * partial month renders its empty slot (see
217
+ {/* Month markers, one slot per week column. A month is
218
+ * labeled on the column carrying its 1st; a partial head
219
+ * month therefore renders an empty slot (see
204
220
  * monthMarkers). */}
205
221
  <div
206
222
  class="hk-calendar-heatmap-markers"
207
223
  style={{
208
224
  gridTemplateColumns: columns.value,
209
225
  columnGap: `${props.cellGap}px`,
226
+ paddingInlineEnd: markersTrailingPad.value,
210
227
  }}
211
228
  >
212
229
  {markers.value.map((m, i) => (
@@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  bucketLevel,
5
5
  calendarGrid,
6
+ heatmapCellFill,
7
+ heatmapCellOpacity,
8
+ HEATMAP_EMPTY_FILL,
9
+ HEATMAP_LEVEL_OPACITY,
6
10
  monthMarkers,
7
11
  shiftIso,
8
12
  todayIso,
@@ -35,6 +39,28 @@ describe("todayIso", () => {
35
39
  });
36
40
  });
37
41
 
42
+ describe("heatmapCellFill / heatmapCellOpacity — the empty cell is its own channel", () => {
43
+ it("fills level 0 with the empty wash and every consuming level with primary", () => {
44
+ expect(heatmapCellFill(0)).toBe(HEATMAP_EMPTY_FILL);
45
+ // The empty wash must never be the primary token in disguise.
46
+ expect(heatmapCellFill(0)).toContain("muted");
47
+ expect(heatmapCellFill(0)).not.toContain("primary");
48
+ for (const level of [1, 2, 3, 4] as const) {
49
+ expect(heatmapCellFill(level)).toBe("var(--hi-color-primary)");
50
+ }
51
+ });
52
+
53
+ it("leaves level 0 without an opacity step and maps 1..4 to the four consuming steps", () => {
54
+ expect(heatmapCellOpacity(0)).toBeUndefined();
55
+ expect(heatmapCellOpacity(1)).toBe(HEATMAP_LEVEL_OPACITY[0]);
56
+ expect(heatmapCellOpacity(2)).toBe(HEATMAP_LEVEL_OPACITY[1]);
57
+ expect(heatmapCellOpacity(3)).toBe(HEATMAP_LEVEL_OPACITY[2]);
58
+ expect(heatmapCellOpacity(4)).toBe(HEATMAP_LEVEL_OPACITY[3]);
59
+ // The ladder only ever holds the four consuming steps.
60
+ expect(HEATMAP_LEVEL_OPACITY).toHaveLength(4);
61
+ });
62
+ });
63
+
38
64
  describe("bucketLevel", () => {
39
65
  it("keeps zero and negative days at level 0", () => {
40
66
  expect(bucketLevel(0, 10)).toBe(0);
@@ -124,10 +150,35 @@ describe("calendarGrid", () => {
124
150
  it("returns an empty grid for an empty series", () => {
125
151
  expect(calendarGrid([], "2025-12-03", null)).toEqual([]);
126
152
  });
153
+
154
+ it("never hatches a day the server shipped, even past a skewed `today`", () => {
155
+ // A skewed server clock (out of contract): the series runs one day
156
+ // past `today`. The shipped day must render as a real day — every
157
+ // day of the series vanishing behind the "after" hatch is the
158
+ // failure mode this guards.
159
+ const cells = calendarGrid(
160
+ [
161
+ { date: "2025-12-01", value: 5 },
162
+ { date: "2025-12-02", value: 3 },
163
+ ],
164
+ "2025-12-01",
165
+ null,
166
+ );
167
+ expect(cells.find((c) => c.date === "2025-12-02")).toMatchObject({
168
+ kind: "day",
169
+ value: 3,
170
+ level: 3,
171
+ });
172
+ // The unshipped padding past it still hatches.
173
+ expect(cells.find((c) => c.date === "2025-12-03")).toMatchObject({
174
+ kind: "gap",
175
+ phase: "after",
176
+ });
177
+ });
127
178
  });
128
179
 
129
- describe("monthMarkers — the leading-month suppression", () => {
130
- it("shows the first marker only when the grid's first column opens its month", () => {
180
+ describe("monthMarkers — a month is labeled on the column carrying its 1st", () => {
181
+ it("shows the first marker when the grid's first column opens its month", () => {
131
182
  // Grid starts exactly on Dec 1 (a Monday AND the 1st); one row of
132
183
  // days pads into exactly one week.
133
184
  const cells = calendarGrid(
@@ -140,9 +191,11 @@ describe("monthMarkers — the leading-month suppression", () => {
140
191
  expect(monthMarkers(cells)).toEqual(["12"]);
141
192
  });
142
193
 
143
- it("suppresses a leading month that starts mid-month; the next month is labeled", () => {
194
+ it("never labels the partial head month — its 1st is not on the axis", () => {
144
195
  // Grid starts Oct 27 (a Monday, NOT the 1st) and runs into November;
145
- // two full weeks of cells.
196
+ // two full weeks of cells. October's 1st is not in the grid, so no
197
+ // column may carry its label; November's 1st sits in the head
198
+ // column (Nov 1 is a Saturday there), so THAT column carries "11".
146
199
  const days: HkCalendarHeatmapDay[] = [
147
200
  { date: "2025-10-27", value: 0 },
148
201
  { date: "2025-10-28", value: 0 },
@@ -157,8 +210,51 @@ describe("monthMarkers — the leading-month suppression", () => {
157
210
  ];
158
211
  const cells = calendarGrid(days, "2025-11-05", null);
159
212
  expect(cells).toHaveLength(14);
160
- // The partial October tail is unlabeled; November's Monday opens.
161
- expect(monthMarkers(cells)).toEqual(["", "11"]);
213
+ const markers = monthMarkers(cells);
214
+ expect(markers).not.toContain("10");
215
+ expect(markers).toEqual(["11", ""]);
216
+ });
217
+
218
+ it("labels a month whose 1st falls mid-column, not one column late", () => {
219
+ // Grid starts Mon Sep 29; Oct 1 (a Wednesday) is in the head
220
+ // column. October is labeled THERE — the old Monday-opened rule
221
+ // pushed it to the next column.
222
+ const days: HkCalendarHeatmapDay[] = Array.from(
223
+ { length: 14 },
224
+ (_, i) => ({ date: shiftIso("2025-09-29", i), value: 0 }),
225
+ );
226
+ const cells = calendarGrid(days, "2025-10-12", null);
227
+ expect(cells).toHaveLength(14);
228
+ expect(monthMarkers(cells)).toEqual(["10", ""]);
229
+ });
230
+
231
+ it("labels a 1st the server shipped past a lead-clocked server (fix 5's boundary)", () => {
232
+ // The ONE way an unarrived 1st ends up labeled: the series shipped
233
+ // it, so fix 5 renders it a real day. A conforming server clamps its
234
+ // window to its own today, so this needs a server clock leading the
235
+ // client's — out of contract, and even then the chart is drawing a
236
+ // day that really carries data.
237
+ const shipped: HkCalendarHeatmapDay[] = [
238
+ { date: "2025-10-27", value: 0 },
239
+ { date: "2025-10-28", value: 0 },
240
+ { date: "2025-10-29", value: 0 },
241
+ { date: "2025-10-30", value: 0 },
242
+ { date: "2025-10-31", value: 0 },
243
+ { date: "2025-11-01", value: 2 },
244
+ ];
245
+ const cells = calendarGrid(shipped, "2025-10-29", null);
246
+ expect(cells.find((c) => c.date === "2025-11-01")).toMatchObject({
247
+ kind: "day",
248
+ });
249
+ // ...and the head column, which now carries November's 1st, labels it
250
+ // while still never labeling its own partial October.
251
+ expect(monthMarkers(cells)).toEqual(["11"]);
252
+
253
+ // The identical axis WITHOUT the shipped 1st: unarrived → unlabeled
254
+ // (the pair pins the boundary).
255
+ const unshipped: HkCalendarHeatmapDay[] = shipped.slice(0, 3);
256
+ const bare = calendarGrid(unshipped, "2025-10-29", null);
257
+ expect(monthMarkers(bare)).toEqual([""]);
162
258
  });
163
259
 
164
260
  it("labels no month when the whole grid sits inside one partial month", () => {
@@ -170,8 +266,44 @@ describe("monthMarkers — the leading-month suppression", () => {
170
266
  expect(monthMarkers(cells)).toEqual([""]);
171
267
  });
172
268
 
269
+ it("does not label a month that has not started yet (a future 1st in the padding)", () => {
270
+ // The axis pads to Sunday, so the column reaches Nov 1–2 while the
271
+ // series (and `today`) stop at Oct 29: those cells are unshipped
272
+ // future gaps. November holds no real day yet — labeling it would
273
+ // claim a month the chart does not show.
274
+ const days: HkCalendarHeatmapDay[] = Array.from(
275
+ { length: 3 },
276
+ (_, i) => ({ date: shiftIso("2025-10-27", i), value: 0 }),
277
+ );
278
+ const cells = calendarGrid(days, "2025-10-29", null);
279
+ expect(cells).toHaveLength(7);
280
+ expect(cells.find((c) => c.date === "2025-11-01")).toMatchObject({
281
+ kind: "gap",
282
+ phase: "after",
283
+ });
284
+ expect(monthMarkers(cells)).toEqual([""]);
285
+ });
286
+
287
+ it("still labels a 1st that fell before the account had data (a before gap)", () => {
288
+ // The suppression test is `after`, not "is a gap": a month the chart
289
+ // draws as unarrived is not labeled, while a PAST month the account
290
+ // has no data for is still an axis and keeps its label. Dropping the
291
+ // phase half of the check (`kind === "gap"`) would go green without
292
+ // this test.
293
+ const days: HkCalendarHeatmapDay[] = Array.from(
294
+ { length: 3 },
295
+ (_, i) => ({ date: shiftIso("2025-12-05", i), value: 0 }),
296
+ );
297
+ const cells = calendarGrid(days, "2025-12-07", "2025-12-02");
298
+ expect(cells.find((c) => c.date === "2025-12-01")).toMatchObject({
299
+ kind: "gap",
300
+ phase: "before",
301
+ });
302
+ expect(monthMarkers(cells)).toEqual(["12"]);
303
+ });
304
+
173
305
  it("keeps the later markers honest about consecutive same-month columns", () => {
174
- // Two December Mondays: only the first carries the label.
306
+ // Two December Mondays: only the column carrying Dec 1 is labeled.
175
307
  const days: HkCalendarHeatmapDay[] = [
176
308
  { date: MON, value: 0 },
177
309
  { date: "2025-12-02", value: 0 },
@@ -187,4 +319,26 @@ describe("monthMarkers — the leading-month suppression", () => {
187
319
  const cells = calendarGrid(days, "2025-12-10", null);
188
320
  expect(monthMarkers(cells)).toEqual(["12", ""]);
189
321
  });
322
+
323
+ it("labels every month of a calendar year exactly once, on the column holding its 1st", () => {
324
+ const days: HkCalendarHeatmapDay[] = Array.from(
325
+ { length: 365 },
326
+ (_, i) => ({ date: shiftIso("2025-01-01", i), value: 0 }),
327
+ );
328
+ // Today = the series' last day, so the padded tail (Jan 1 2026) is
329
+ // still unarrived and carries no label.
330
+ const cells = calendarGrid(days, "2025-12-31", null);
331
+ const markers = monthMarkers(cells);
332
+ expect(markers.filter((m) => m !== "")).toEqual([
333
+ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12",
334
+ ]);
335
+ // Each label sits on the column whose dates include that month's 1st.
336
+ markers.forEach((label, col) => {
337
+ if (!label) return;
338
+ const dates = cells.slice(col * 7, col * 7 + 7).map((c) => c.date);
339
+ expect(
340
+ dates.some((d) => d.slice(8, 10) === "01" && Number(d.slice(5, 7)) === Number(label)),
341
+ ).toBe(true);
342
+ });
343
+ });
190
344
  });
@@ -11,11 +11,39 @@
11
11
  * derivation is unit-testable without a component mount.
12
12
  */
13
13
 
14
- /** Opacity per bucket (0–4) over the primary token; bucket 0 is the
15
- * muted empty cell. Five steps so "no consumption" stays visually
16
- * distinct from "the smallest consumption on record". Exported for the
17
- * component and its tests — the scale is part of the contract. */
18
- export const HEATMAP_LEVEL_OPACITY = ["0.12", "0.22", "0.42", "0.66", "0.95"] as const;
14
+ /** The empty cell (level 0) and the legend's first swatch fill: the
15
+ * muted token washed out so "no consumption" stays visually distinct
16
+ * from "the smallest consumption on record". Level 0 has no opacity
17
+ * step of its own — it may not be re-tinted by the primary token at
18
+ * all. */
19
+ export const HEATMAP_EMPTY_FILL =
20
+ "color-mix(in srgb, var(--hi-color-muted) 40%, transparent)";
21
+
22
+ /** Opacity per CONSUMING bucket (levels 1–4) over the primary token,
23
+ * four steps low → peak. The array is indexed by `level - 1` (level 0
24
+ * is `HEATMAP_EMPTY_FILL`, never an opacity step); the legend renders
25
+ * the empty swatch plus these four. Exported for the component and its
26
+ * tests — the scale is part of the contract. */
27
+ export const HEATMAP_LEVEL_OPACITY = ["0.22", "0.42", "0.66", "0.95"] as const;
28
+
29
+ /** The fill for one cell: level 0 carries the empty wash, every
30
+ * consuming level rides the primary token. The empty cell is never
31
+ * re-tinted primary and never takes an opacity step (the reference
32
+ * calendar this upstreaming followed draws it the same way), so the
33
+ * two channels stay independent — pinned as a helper because happy-dom
34
+ * drops `color-mix()` out of a parsed `background`. */
35
+ export function heatmapCellFill(level: 0 | 1 | 2 | 3 | 4): string {
36
+ return level === 0 ? HEATMAP_EMPTY_FILL : "var(--hi-color-primary)";
37
+ }
38
+
39
+ /** The opacity step for one cell: level 0 has none (its wash is the
40
+ * fill), consuming levels index [`HEATMAP_LEVEL_OPACITY`] by
41
+ * `level - 1`. */
42
+ export function heatmapCellOpacity(
43
+ level: 0 | 1 | 2 | 3 | 4,
44
+ ): string | undefined {
45
+ return level === 0 ? undefined : HEATMAP_LEVEL_OPACITY[level - 1];
46
+ }
19
47
 
20
48
  /** One input day of the dense series. `value` may be negative or zero
21
49
  * (a "no consumption" day) — it still buckets to level 0. */
@@ -81,7 +109,15 @@ export function calendarGrid(
81
109
  const cells: CalendarCell[] = [];
82
110
  for (let i = 0; i < span; i++) {
83
111
  const date = shiftIso(gridStart, i);
84
- if (date > today) {
112
+ // Never hatch a date the server actually shipped: a series whose
113
+ // last row runs past `today` (a skewed server clock, out of
114
+ // contract) would otherwise render EVERY one of its days as a
115
+ // future gap — the data would vanish behind the hatch. (The
116
+ // before-first hatch below deliberately does NOT consult the map:
117
+ // the server fills the pre-anchor stretch with dense zeros as
118
+ // filler, and the account's first-activity anchor is what decides
119
+ // that no data existed, however many filler rows arrive.)
120
+ if (date > today && !valueByDate.has(date)) {
85
121
  cells.push({ kind: "gap", date, phase: "after" });
86
122
  continue;
87
123
  }
@@ -108,32 +144,51 @@ export function bucketLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {
108
144
  }
109
145
 
110
146
  /**
111
- * Month markers for the week columns: a short label on each column
112
- * whose Monday opens a month, "" otherwise.
147
+ * Month markers for the week columns: a short label over the column
148
+ * that CONTAINS a month's first day (the 1st), "" otherwise.
149
+ *
150
+ * The leading-month rule (2026-10 user direction) falls out of this
151
+ * anchor instead of being a special case: a trailing-year window
152
+ * usually starts MID-month, so the head column carries only that
153
+ * month's tail — the head month's 1st is not in the grid, so it is
154
+ * never labeled. A month whose 1st IS on the axis is labeled on the
155
+ * column showing it (which may be mid-column rather than the next
156
+ * Monday), so no label lands one column late.
113
157
  *
114
- * The leading month is the 2026-10 refinement this extraction carries:
115
- * a trailing-year window (or any window) usually starts MID-month, so
116
- * the first column shows only the tail of its month. A label there
117
- * would claim a month whose start is not visible on the chart — the
118
- * GitHub convention is to suppress it. The first marker is therefore
119
- * shown ONLY when the grid's first column actually opens its month
120
- * (its Monday is the 1st); every later marker keeps the previous
121
- * rule (this column's Monday opens a month the previous column's
122
- * Monday did not). Every grid cell carries a real calendar date, so
123
- * the marker can never lie about padding.
158
+ * The one suppression is the unarrived tail: the padded axis may reach
159
+ * into next month's first days as "after" cells, and a 1st the chart
160
+ * itself renders as unarrived carries no label — a label there would
161
+ * announce a month the chart does not draw yet. The test rides the
162
+ * cell's PHASE, not its data: a 1st the server shipped is a real day
163
+ * and labels normally (only a server whose clock leads the client's,
164
+ * out of contract, can ship one), and a past month the account has no
165
+ * data for at all is still an axis the chart draws, so it keeps its
166
+ * label. Every grid cell carries a real calendar date, so the marker
167
+ * can never lie about padding.
124
168
  */
125
169
  export function monthMarkers(cells: CalendarCell[]): string[] {
126
170
  const weeks = Math.ceil(cells.length / 7);
127
171
  const markers: string[] = [];
128
- let prevMonth = -1;
172
+ // `lastFirstMonth` is belt-and-braces for callers that hand this helper
173
+ // a sparse or hand-built cell list: a contiguous axis never carries a
174
+ // month's 1st twice (a 7-day column holds at most one "01"), so on
175
+ // calendarGrid output the guard never fires. It mirrors the reference
176
+ // grid's own `labeledMonth` dedup.
177
+ let lastFirstMonth = -1;
129
178
  for (let w = 0; w < weeks; w++) {
130
- const monday = cells[w * 7];
131
- if (!monday) break;
132
- const month = Number(monday.date.slice(5, 7));
133
- const opensMonth =
134
- w === 0 ? monday.date.slice(8, 10) === "01" : month !== prevMonth;
135
- markers.push(opensMonth ? String(month) : "");
136
- prevMonth = month;
179
+ let month = -1;
180
+ for (let d = 0; d < 7; d++) {
181
+ const cell = cells[w * 7 + d];
182
+ if (!cell) break;
183
+ const isMonthStart = cell.date.slice(8, 10) === "01";
184
+ const isFuture = cell.kind === "gap" && cell.phase === "after";
185
+ if (isMonthStart && !isFuture) {
186
+ month = Number(cell.date.slice(5, 7));
187
+ break;
188
+ }
189
+ }
190
+ markers.push(month !== -1 && month !== lastFirstMonth ? String(month) : "");
191
+ if (month !== -1) lastFirstMonth = month;
137
192
  }
138
193
  return markers;
139
194
  }
package/src/index.ts CHANGED
@@ -660,6 +660,9 @@ export { default as HCalendarHeatmap } from "./components/HkCalendarHeatmap";
660
660
  export {
661
661
  bucketLevel,
662
662
  calendarGrid,
663
+ heatmapCellFill,
664
+ heatmapCellOpacity,
665
+ HEATMAP_EMPTY_FILL,
663
666
  HEATMAP_LEVEL_OPACITY,
664
667
  monthMarkers,
665
668
  todayIso,