@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 +1 -1
- package/src/components/HkCalendarHeatmap.barrel.test.ts +48 -0
- package/src/components/HkCalendarHeatmap.test.tsx +168 -25
- package/src/components/HkCalendarHeatmap.tsx +57 -40
- package/src/components/calendarHeatmap.test.ts +161 -7
- package/src/components/calendarHeatmap.ts +81 -26
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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,
|
|
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,
|
|
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("
|
|
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
|
|
65
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
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("
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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: [
|
|
101
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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):
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
175
|
+
{LEGEND_LEVELS.map((level) => (
|
|
158
176
|
<span
|
|
159
|
-
key={
|
|
160
|
-
data-legend-level={
|
|
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
|
-
|
|
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
|
|
203
|
-
*
|
|
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 —
|
|
130
|
-
it("shows the first marker
|
|
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("
|
|
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
|
-
|
|
161
|
-
expect(
|
|
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
|
|
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
|
-
/**
|
|
15
|
-
* muted
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
*
|
|
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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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,
|