@celestia-island/hikari 0.55.230 → 0.55.232
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/HkAvatarButton.test.tsx +58 -0
- package/src/components/HkCalendarHeatmap.scss +90 -0
- package/src/components/HkCalendarHeatmap.test.tsx +240 -0
- package/src/components/HkCalendarHeatmap.tsx +261 -0
- package/src/components/calendarHeatmap.test.ts +190 -0
- package/src/components/calendarHeatmap.ts +139 -0
- package/src/i18n/locales/ar/components.json +6 -1
- package/src/i18n/locales/de/components.json +6 -1
- package/src/i18n/locales/en/components.json +6 -1
- package/src/i18n/locales/es/components.json +6 -1
- package/src/i18n/locales/fr/components.json +6 -1
- package/src/i18n/locales/ja/components.json +6 -1
- package/src/i18n/locales/ko/components.json +6 -1
- package/src/i18n/locales/pt/components.json +6 -1
- package/src/i18n/locales/ru/components.json +6 -1
- package/src/i18n/locales/zh-Hans/components.json +6 -1
- package/src/i18n/locales/zh-Hant/components.json +6 -1
- package/src/index.ts +14 -0
- package/src/styles/admin-tokens.scss +10 -0
package/package.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
7
|
import { HkAvatarButton } from "./HkAvatarButton";
|
|
5
8
|
import { HkAdminHeader } from "./HkAdminHeader";
|
|
@@ -107,6 +110,61 @@ describe("HkAvatarButton", () => {
|
|
|
107
110
|
});
|
|
108
111
|
});
|
|
109
112
|
|
|
113
|
+
describe("HkAvatarButton sheet contract", () => {
|
|
114
|
+
// happy-dom has no layout engine, so the geometry is pinned at the
|
|
115
|
+
// source level — the same approach the downstream chest tripwire
|
|
116
|
+
// (headerAvatarGeometry.test.ts) takes against the INSTALLED sheet.
|
|
117
|
+
const sheet = readFileSync(
|
|
118
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "styles", "admin-tokens.scss"),
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/** Declarations of the rule whose whole prelude IS `selector`
|
|
123
|
+
* (a compound/descendant spelling is a different rule and does not
|
|
124
|
+
* match), comments stripped; null when no such rule exists — every
|
|
125
|
+
* assertion treats null as a failure, never as "no objection". */
|
|
126
|
+
function ruleDeclarations(selector: string): Array<[string, string]> | null {
|
|
127
|
+
const code = sheet
|
|
128
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
129
|
+
.replace(/(^|[^:])\/\/[^\n]*/gm, "$1");
|
|
130
|
+
for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
|
|
131
|
+
const open = code.indexOf("{", at + selector.length);
|
|
132
|
+
if (open < 0) continue;
|
|
133
|
+
if (code.slice(at + selector.length, open).trim() !== "") continue;
|
|
134
|
+
let boundary = at - 1;
|
|
135
|
+
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
136
|
+
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
137
|
+
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
138
|
+
const out: Array<[string, string]> = [];
|
|
139
|
+
for (const part of body.split(";")) {
|
|
140
|
+
const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
|
|
141
|
+
if (m) out.push([m[1], m[2].replace(/\s+/g, " ")]);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it("the trigger is a BLOCK — hosts may nest it in a plain wrapper div", () => {
|
|
149
|
+
// The chat face wraps HeaderLeft's trigger in a menu-anchor <div>:
|
|
150
|
+
// an inline-block button there joins a line box and the strut's
|
|
151
|
+
// descender space lands BELOW it, riding the avatar ~4px high inside
|
|
152
|
+
// a perfectly centred wrapper (2026-10-30 user report). Removing
|
|
153
|
+
// this declaration is the mutation this test must catch.
|
|
154
|
+
const rule = ruleDeclarations(".s-admin-header-avatar");
|
|
155
|
+
expect(rule, "admin-tokens must declare .s-admin-header-avatar").not.toBeNull();
|
|
156
|
+
// Positive control for the extractor itself: the geometry pair the
|
|
157
|
+
// downstream tripwire also reads. If these stop matching, the parser
|
|
158
|
+
// above is broken — fix it, not the assertion below.
|
|
159
|
+
expect(rule).toContainEqual(["width", "var(--space-28)"]);
|
|
160
|
+
expect(rule).toContainEqual(["height", "var(--space-28)"]);
|
|
161
|
+
expect(rule, "the trigger must not re-enter inline formatting").toContainEqual([
|
|
162
|
+
"display",
|
|
163
|
+
"block",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
110
168
|
describe("HkAdminHeader × HkAvatarButton / identity slot", () => {
|
|
111
169
|
it("never renders aria-expanded when no expanded prop is given (drawer-mode contract)", () => {
|
|
112
170
|
// R2 S3-2: menu engines read this pair; a drawer-mode trigger has no
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HkCalendarHeatmap — GitHub-style calendar heatmap anatomy.
|
|
2
|
+
//
|
|
3
|
+
// Self-sufficient styling (the component self-sufficiency guard): every
|
|
4
|
+
// class lives here, no host atomic/utility layer. The dynamic geometry
|
|
5
|
+
// (cell size, gap, week-column count, rail row rhythm) stays inline in
|
|
6
|
+
// the component; this sheet owns the static layout and typography.
|
|
7
|
+
|
|
8
|
+
.hk-calendar-heatmap {
|
|
9
|
+
// The legend row: scale on the LEFT, toolbar slot on the RIGHT.
|
|
10
|
+
&-head {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-wrap: wrap;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
column-gap: 16px;
|
|
16
|
+
row-gap: 8px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&-legend {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 4px;
|
|
23
|
+
font-size: 10px;
|
|
24
|
+
color: var(--hi-color-muted);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// w-max + auto margins center the whole calendar (rail included) on
|
|
28
|
+
// wide cards while letting narrow screens scroll from the left edge —
|
|
29
|
+
// auto margins resolve to zero when the content overflows, so mobile
|
|
30
|
+
// keeps its natural scroll origin.
|
|
31
|
+
&-scroll {
|
|
32
|
+
margin-top: 12px;
|
|
33
|
+
overflow-x: auto;
|
|
34
|
+
padding-bottom: 4px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&-body {
|
|
38
|
+
display: flex;
|
|
39
|
+
width: max-content;
|
|
40
|
+
margin-inline: auto;
|
|
41
|
+
gap: 8px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Weekday rail: the first (Monday) and last (Sunday) rows carry the
|
|
45
|
+
// labels (the wowsp convention); sticky so it survives horizontal
|
|
46
|
+
// scroll on narrow screens.
|
|
47
|
+
&-rail {
|
|
48
|
+
position: sticky;
|
|
49
|
+
left: 0;
|
|
50
|
+
z-index: 1;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
padding-right: 2px;
|
|
55
|
+
font-size: 10px;
|
|
56
|
+
line-height: 14px;
|
|
57
|
+
color: var(--hi-color-muted);
|
|
58
|
+
background: var(--hi-color-surface);
|
|
59
|
+
|
|
60
|
+
> span {
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&-cells {
|
|
66
|
+
flex: 1;
|
|
67
|
+
min-width: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Month markers, one slot per week column; labels overflow their
|
|
71
|
+
// 14px column into the empty cells ahead (the GitHub convention — a
|
|
72
|
+
// month marker is always followed by unmarked columns), so no
|
|
73
|
+
// truncating: a clipped "O…" tells nothing.
|
|
74
|
+
&-markers {
|
|
75
|
+
display: grid;
|
|
76
|
+
margin-bottom: 4px;
|
|
77
|
+
font-size: 10px;
|
|
78
|
+
line-height: 12px;
|
|
79
|
+
color: var(--hi-color-muted);
|
|
80
|
+
|
|
81
|
+
> span {
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The grid itself: cells flow column-first into 7 rows.
|
|
87
|
+
&-grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkCalendarHeatmap from "./HkCalendarHeatmap";
|
|
5
|
+
import { HEATMAP_LEVEL_OPACITY, todayIso } from "./calendarHeatmap";
|
|
6
|
+
|
|
7
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
8
|
+
|
|
9
|
+
function mount(node: ReturnType<typeof h>) {
|
|
10
|
+
const container = document.createElement("div");
|
|
11
|
+
document.body.appendChild(container);
|
|
12
|
+
const app = createApp({ render: () => node });
|
|
13
|
+
app.mount(container);
|
|
14
|
+
mounts.push({ app, container });
|
|
15
|
+
return container;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
20
|
+
app.unmount();
|
|
21
|
+
container.remove();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A Monday, NOT the 1st — the leading-partial-month shape. */
|
|
26
|
+
const MID_MONTH_MON = "2025-10-27";
|
|
27
|
+
/** A Monday AND the 1st — the clean head shape. */
|
|
28
|
+
const CLEAN_HEAD_MON = "2025-12-01";
|
|
29
|
+
|
|
30
|
+
const daysFrom = (start: string, count: number, value = 0) =>
|
|
31
|
+
Array.from({ length: count }, (_, i) => {
|
|
32
|
+
const d = new Date(`${start}T00:00:00Z`);
|
|
33
|
+
d.setUTCDate(d.getUTCDate() + i);
|
|
34
|
+
return { date: d.toISOString().slice(0, 10), value };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("HkCalendarHeatmap", () => {
|
|
38
|
+
it("renders the legend (less → more, five swatches) and the grid anatomy", () => {
|
|
39
|
+
const c = mount(
|
|
40
|
+
h(HkCalendarHeatmap, {
|
|
41
|
+
days: daysFrom(CLEAN_HEAD_MON, 7),
|
|
42
|
+
first: null,
|
|
43
|
+
unit: "度",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
47
|
+
expect(grid).toBeTruthy();
|
|
48
|
+
expect(grid.getAttribute("role")).toBe("img");
|
|
49
|
+
// One row of days pads into exactly one week.
|
|
50
|
+
expect(grid.getAttribute("aria-label")).toContain("1");
|
|
51
|
+
const legend = c.querySelector('[data-testid="hk-calendar-heatmap-legend"]');
|
|
52
|
+
expect(legend?.textContent).toContain("Less");
|
|
53
|
+
expect(legend?.textContent).toContain("More");
|
|
54
|
+
expect(c.querySelectorAll("[data-legend-level]")).toHaveLength(
|
|
55
|
+
HEATMAP_LEVEL_OPACITY.length,
|
|
56
|
+
);
|
|
57
|
+
// One week of days, all level 0 (no consumption): plain muted cells.
|
|
58
|
+
expect(grid.querySelectorAll('[data-level="0"]')).toHaveLength(7);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("suppresses the leading month marker when the window starts mid-month", async () => {
|
|
62
|
+
const c = mount(
|
|
63
|
+
h(HkCalendarHeatmap, {
|
|
64
|
+
// Trailing-October tail + November: the partial October head is
|
|
65
|
+
// unlabeled, November's Monday carries the only marker.
|
|
66
|
+
days: daysFrom(MID_MONTH_MON, 10),
|
|
67
|
+
first: null,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
await nextTick();
|
|
71
|
+
const markers = c.querySelectorAll(
|
|
72
|
+
".hk-calendar-heatmap-markers > span",
|
|
73
|
+
) as NodeListOf<HTMLElement>;
|
|
74
|
+
expect(markers.length).toBeGreaterThanOrEqual(2);
|
|
75
|
+
expect(markers[0].textContent).toBe(""); // partial October — suppressed
|
|
76
|
+
expect(markers[1].textContent).not.toBe(""); // November — labeled
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shows the leading month marker when the grid's first column opens its month", async () => {
|
|
80
|
+
const c = mount(
|
|
81
|
+
h(HkCalendarHeatmap, {
|
|
82
|
+
days: daysFrom(CLEAN_HEAD_MON, 9),
|
|
83
|
+
first: null,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
await nextTick();
|
|
87
|
+
const markers = c.querySelectorAll(
|
|
88
|
+
".hk-calendar-heatmap-markers > span",
|
|
89
|
+
) as NodeListOf<HTMLElement>;
|
|
90
|
+
expect(markers[0].textContent).not.toBe(""); // Dec 1 — labeled
|
|
91
|
+
});
|
|
92
|
+
|
|
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();
|
|
98
|
+
const c = mount(
|
|
99
|
+
h(HkCalendarHeatmap, {
|
|
100
|
+
days: [{ date: today, value: 5 }],
|
|
101
|
+
first: today,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
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")),
|
|
109
|
+
);
|
|
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");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("tooltips day cells with the unit when one is given", () => {
|
|
120
|
+
const c = mount(
|
|
121
|
+
h(HkCalendarHeatmap, {
|
|
122
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
123
|
+
first: null,
|
|
124
|
+
unit: "度",
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
128
|
+
expect(cell).toBeTruthy();
|
|
129
|
+
// The UTC zone is explicit (R2: a negative-offset host must not
|
|
130
|
+
// land the tooltip a day off) — the date is TZ-deterministic.
|
|
131
|
+
expect(cell.getAttribute("title")).toContain("Dec 1, 2025");
|
|
132
|
+
expect(cell.getAttribute("title")).toContain("5");
|
|
133
|
+
expect(cell.getAttribute("title")).toContain("度");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("wires the max prop into the cell buckets, not just data-max", () => {
|
|
137
|
+
const c = mount(
|
|
138
|
+
h(HkCalendarHeatmap, {
|
|
139
|
+
// Peak 10 in-window; the pinned max 100 flattens both days to
|
|
140
|
+
// level 1 (R2 S1: the prop was a dead wire to data-max).
|
|
141
|
+
days: [{ date: CLEAN_HEAD_MON, value: 10 }, { date: "2025-12-02", value: 1 }],
|
|
142
|
+
first: null,
|
|
143
|
+
max: 100,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
147
|
+
expect(grid.getAttribute("data-max")).toBe("100");
|
|
148
|
+
expect(grid.querySelectorAll('[data-level="1"]')).toHaveLength(2);
|
|
149
|
+
expect(grid.querySelectorAll('[data-level="4"]')).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("tooltips day cells without a dangling separator when unitless", () => {
|
|
153
|
+
const c = mount(
|
|
154
|
+
h(HkCalendarHeatmap, {
|
|
155
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
156
|
+
first: null,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
160
|
+
// The unitless title ends at the count — no trailing "· <empty>".
|
|
161
|
+
expect(cell.getAttribute("title")).toMatch(/· 5$/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("labels the weekday rail on the first and last rows only", () => {
|
|
165
|
+
const c = mount(
|
|
166
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
|
|
167
|
+
);
|
|
168
|
+
const rail = c.querySelector(".hk-calendar-heatmap-rail") as HTMLElement;
|
|
169
|
+
const rows = rail.querySelectorAll(":scope > span") as NodeListOf<HTMLElement>;
|
|
170
|
+
expect(rows).toHaveLength(7);
|
|
171
|
+
expect(rows[0].textContent).not.toBe(""); // Monday
|
|
172
|
+
expect(rows[6].textContent).not.toBe(""); // Sunday
|
|
173
|
+
expect(rows[3].textContent).toBe(""); // middle rows unlabeled
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("renders the toolbar slot right of the legend", () => {
|
|
177
|
+
const Toolbar = defineComponent({
|
|
178
|
+
name: "FakeToolbar",
|
|
179
|
+
setup: () => () => h("div", { "data-fake-toolbar": "" }, "tabs"),
|
|
180
|
+
});
|
|
181
|
+
const c = mount(
|
|
182
|
+
h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }, {
|
|
183
|
+
toolbar: () => h(Toolbar),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
expect(c.querySelector("[data-fake-toolbar]")?.textContent).toBe("tabs");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("renders an empty grid without markers for an empty series", () => {
|
|
190
|
+
const c = mount(h(HkCalendarHeatmap, { days: [], first: null }));
|
|
191
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
192
|
+
expect(grid).toBeTruthy();
|
|
193
|
+
expect(grid.querySelectorAll("[data-level]")).toHaveLength(0);
|
|
194
|
+
expect(c.querySelectorAll(".hk-calendar-heatmap-markers > span")).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("re-takes today per recompute — a refetch across UTC midnight un-gaps the new today cell", async () => {
|
|
198
|
+
// R2 S2 + R3 变异留证: today frozen at setup rendered the next
|
|
199
|
+
// day as an "after" gap even after the caller refetched a series
|
|
200
|
+
// that includes it. A recompute (the refetch's new `days`) must
|
|
201
|
+
// re-evaluate todayIso() — the crossed-midnight day becomes a day.
|
|
202
|
+
// (A rerender with NO dep change legitimately keeps the cached
|
|
203
|
+
// cells — computed semantics, nothing changed.)
|
|
204
|
+
vi.useFakeTimers();
|
|
205
|
+
try {
|
|
206
|
+
vi.setSystemTime(new Date("2025-12-01T10:00:00Z"));
|
|
207
|
+
// Dense series through the mount-time today: Fri 11-28 .. Mon 12-01.
|
|
208
|
+
const days = daysFrom("2025-11-28", 4);
|
|
209
|
+
const state = reactive({ days });
|
|
210
|
+
// The vnode is built INSIDE the render so the reactive `days`
|
|
211
|
+
// re-renders — the shared eager mount helper would capture one
|
|
212
|
+
// static vnode.
|
|
213
|
+
const container = document.createElement("div");
|
|
214
|
+
document.body.appendChild(container);
|
|
215
|
+
const app = createApp({
|
|
216
|
+
render: () => h(HkCalendarHeatmap, { days: state.days, first: null, unit: "度" }),
|
|
217
|
+
});
|
|
218
|
+
app.mount(container);
|
|
219
|
+
mounts.push({ app, container });
|
|
220
|
+
const grid = container.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
221
|
+
const tomorrowAtMount = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
|
|
222
|
+
expect(tomorrowAtMount.getAttribute("data-gap")).toBe("after");
|
|
223
|
+
|
|
224
|
+
// Cross UTC midnight; the refetched series now includes the new
|
|
225
|
+
// today (12-02, 3 度) — a new array instance, so the cells
|
|
226
|
+
// computed recomputes and re-takes today.
|
|
227
|
+
vi.setSystemTime(new Date("2025-12-02T10:00:00Z"));
|
|
228
|
+
state.days = [...days, { date: "2025-12-02", value: 3 }];
|
|
229
|
+
// Two ticks: the parent's render → the child's props update → the
|
|
230
|
+
// child's re-render land in sequence.
|
|
231
|
+
await nextTick();
|
|
232
|
+
await nextTick();
|
|
233
|
+
const nowToday = grid.querySelector('[data-date="2025-12-02"]') as HTMLElement;
|
|
234
|
+
expect(nowToday.getAttribute("data-gap")).toBeNull();
|
|
235
|
+
expect(nowToday.getAttribute("data-level")).toBe("4");
|
|
236
|
+
} finally {
|
|
237
|
+
vi.useRealTimers();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { computed, defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
import {
|
|
5
|
+
bucketLevel,
|
|
6
|
+
calendarGrid,
|
|
7
|
+
HEATMAP_LEVEL_OPACITY,
|
|
8
|
+
monthMarkers,
|
|
9
|
+
todayIso,
|
|
10
|
+
type CalendarCell,
|
|
11
|
+
type HkCalendarHeatmapDay,
|
|
12
|
+
} from "./calendarHeatmap";
|
|
13
|
+
import "./HkCalendarHeatmap.scss";
|
|
14
|
+
|
|
15
|
+
/** The no-data cell (before the account's first activity / after
|
|
16
|
+
* today): muted diagonal hatch over the surface — the wowsp-style
|
|
17
|
+
* "not yet" cell. Rendered inert: no tooltip, no pointer events. */
|
|
18
|
+
const GAP_HATCH =
|
|
19
|
+
"repeating-linear-gradient(45deg, color-mix(in srgb, var(--hi-color-muted) 70%, transparent) 0px, color-mix(in srgb, var(--hi-color-muted) 70%, transparent) 2px, transparent 2px, transparent 4px)";
|
|
20
|
+
|
|
21
|
+
/** Minimal {name} interpolation for the component's i18n strings — the
|
|
22
|
+
* hikari context serves plain strings (no ICU), the placeholders stay
|
|
23
|
+
* in the locale bundles so translators keep the full sentence. */
|
|
24
|
+
function fill(template: string, vars: Record<string, string | number>): string {
|
|
25
|
+
return template.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ""));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* HkCalendarHeatmap — a GitHub-style calendar heatmap of daily values.
|
|
30
|
+
*
|
|
31
|
+
* Upstreamed from the shittim-chest quota page's degree-usage section
|
|
32
|
+
* (2026-10 user direction: a calendar heatmap is a big business
|
|
33
|
+
* component — the rich-text-viewer table — and the leading-month
|
|
34
|
+
* marker refinement belongs in the library, not the consumer). A
|
|
35
|
+
* legend (less → more over the primary token), a Monday-first weekday
|
|
36
|
+
* rail, month markers, and hatched no-data gap cells that never claim
|
|
37
|
+
* a fake zero.
|
|
38
|
+
*
|
|
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.
|
|
44
|
+
*
|
|
45
|
+
* Dumb by design: the caller owns the fetch lifecycle and passes the
|
|
46
|
+
* settled dense series in (`days`, oldest → newest, zero-filled,
|
|
47
|
+
* inclusive of today) plus the account's first activity date (`first`,
|
|
48
|
+
* null = never). All math lives in ./calendarHeatmap (pure, pinned by
|
|
49
|
+
* its own tests). The `toolbar` slot rides right of the legend — the
|
|
50
|
+
* consumer's range tabs / controls belong there.
|
|
51
|
+
*/
|
|
52
|
+
export default defineComponent({
|
|
53
|
+
name: "HkCalendarHeatmap",
|
|
54
|
+
props: {
|
|
55
|
+
/** The dense daily series, oldest → newest, inclusive of today. */
|
|
56
|
+
days: {
|
|
57
|
+
type: Array as PropType<HkCalendarHeatmapDay[]>,
|
|
58
|
+
required: true,
|
|
59
|
+
},
|
|
60
|
+
/** The account's first activity date (null = never). */
|
|
61
|
+
first: { type: String as PropType<string | null>, default: null },
|
|
62
|
+
/** The unit the legend/tooltip names ("" = unitless values). */
|
|
63
|
+
unit: { type: String, default: "" },
|
|
64
|
+
/** Peak day pinned across windows (reaches the cell buckets, not
|
|
65
|
+
* just the data-max attribute); null = derive from `days`. */
|
|
66
|
+
max: { type: Number as PropType<number | null>, default: null },
|
|
67
|
+
/** Square-cell scale (GitHub-scale: a 53-week grid stays compact). */
|
|
68
|
+
cellSize: { type: Number, default: 14 },
|
|
69
|
+
cellGap: { type: Number, default: 3 },
|
|
70
|
+
},
|
|
71
|
+
setup(props, { slots }) {
|
|
72
|
+
const { t } = useI18n();
|
|
73
|
+
|
|
74
|
+
// The rendered calendar follows the selected range; `today` is
|
|
75
|
+
// taken per recompute (R2: frozen at setup, a long-mounted
|
|
76
|
+
// component crossing UTC midnight rendered its today cell as an
|
|
77
|
+
// "after" gap until the next remount), and the `max` prop pins the
|
|
78
|
+
// intensity scale — it must reach the buckets, not just the
|
|
79
|
+
// data-max attribute.
|
|
80
|
+
const cells = computed(() =>
|
|
81
|
+
calendarGrid(props.days, todayIso(), props.first, props.max),
|
|
82
|
+
);
|
|
83
|
+
const markers = computed(() => monthMarkers(cells.value));
|
|
84
|
+
const weeks = computed(() => Math.ceil(cells.value.length / 7));
|
|
85
|
+
const max = computed(() =>
|
|
86
|
+
props.max ?? props.days.reduce((m, d) => Math.max(m, d.value), 0),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const columns = computed(
|
|
90
|
+
() => `repeat(${weeks.value}, ${props.cellSize}px)`,
|
|
91
|
+
);
|
|
92
|
+
|
|
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).
|
|
99
|
+
const locale = computed(() => useI18n().locale);
|
|
100
|
+
const weekdayLabel = (idx: number) =>
|
|
101
|
+
new Intl.DateTimeFormat(locale.value, {
|
|
102
|
+
weekday: "short",
|
|
103
|
+
timeZone: "UTC",
|
|
104
|
+
}).format(
|
|
105
|
+
// Any Monday-first week works — take 2024-01-01 (a Monday).
|
|
106
|
+
new Date(Date.UTC(2024, 0, 1 + idx)),
|
|
107
|
+
);
|
|
108
|
+
const monthLabel = (m: string) => {
|
|
109
|
+
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)));
|
|
114
|
+
};
|
|
115
|
+
const dateLabel = (iso: string) =>
|
|
116
|
+
new Intl.DateTimeFormat(locale.value, {
|
|
117
|
+
dateStyle: "medium",
|
|
118
|
+
timeZone: "UTC",
|
|
119
|
+
}).format(new Date(`${iso}T00:00:00Z`));
|
|
120
|
+
|
|
121
|
+
const cellTitle = (d: Extract<CalendarCell, { kind: "day" }>) =>
|
|
122
|
+
fill(
|
|
123
|
+
props.unit
|
|
124
|
+
? t("hikari::calendarHeatmap.cellTitle", "{date} · {n} {unit}")
|
|
125
|
+
: t("hikari::calendarHeatmap.cellTitleNoUnit", "{date} · {n}"),
|
|
126
|
+
{ date: dateLabel(d.date), n: d.value, unit: props.unit },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const cellStyle = (cell: CalendarCell) =>
|
|
130
|
+
cell.kind === "gap"
|
|
131
|
+
? {
|
|
132
|
+
borderRadius: "3px",
|
|
133
|
+
background: GAP_HATCH,
|
|
134
|
+
pointerEvents: "none" as const,
|
|
135
|
+
}
|
|
136
|
+
: {
|
|
137
|
+
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],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return () => (
|
|
146
|
+
<div class="hk-calendar-heatmap">
|
|
147
|
+
{/* The legend row: the scale in the caller's one unit on the
|
|
148
|
+
* LEFT, the toolbar slot on the RIGHT — the strip belongs to
|
|
149
|
+
* the centered grid's tail (2026-10-15 chest user direction,
|
|
150
|
+
* carried upstream with the component). */}
|
|
151
|
+
<div class="hk-calendar-heatmap-head">
|
|
152
|
+
<span
|
|
153
|
+
class="hk-calendar-heatmap-legend"
|
|
154
|
+
data-testid="hk-calendar-heatmap-legend"
|
|
155
|
+
>
|
|
156
|
+
{t("hikari::calendarHeatmap.less", "Less")}
|
|
157
|
+
{HEATMAP_LEVEL_OPACITY.map((o, i) => (
|
|
158
|
+
<span
|
|
159
|
+
key={i}
|
|
160
|
+
data-legend-level={i}
|
|
161
|
+
style={{
|
|
162
|
+
width: "10px",
|
|
163
|
+
height: "10px",
|
|
164
|
+
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,
|
|
170
|
+
display: "inline-block",
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
{t("hikari::calendarHeatmap.more", "More")}
|
|
175
|
+
</span>
|
|
176
|
+
{slots.toolbar?.({})}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* The scroll wrapper centers the whole calendar (rail included)
|
|
180
|
+
* on wide cards while letting narrow screens scroll from the
|
|
181
|
+
* left edge (see the SCSS). */}
|
|
182
|
+
<div class="hk-calendar-heatmap-scroll">
|
|
183
|
+
<div class="hk-calendar-heatmap-body">
|
|
184
|
+
{/* Weekday rail: the first (Monday) and last (Sunday) rows
|
|
185
|
+
* carry the labels, the wowsp convention. */}
|
|
186
|
+
<div
|
|
187
|
+
aria-hidden="true"
|
|
188
|
+
class="hk-calendar-heatmap-rail"
|
|
189
|
+
style={{ rowGap: `${props.cellGap}px` }}
|
|
190
|
+
>
|
|
191
|
+
{[0, 1, 2, 3, 4, 5, 6].map((row) => (
|
|
192
|
+
<span
|
|
193
|
+
key={row}
|
|
194
|
+
style={{ height: `${props.cellSize}px`, lineHeight: `${props.cellSize}px` }}
|
|
195
|
+
>
|
|
196
|
+
{row === 0 || row === 6 ? weekdayLabel(row) : ""}
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="hk-calendar-heatmap-cells">
|
|
202
|
+
{/* Month markers, one slot per week column. A leading
|
|
203
|
+
* partial month renders its empty slot (see
|
|
204
|
+
* monthMarkers). */}
|
|
205
|
+
<div
|
|
206
|
+
class="hk-calendar-heatmap-markers"
|
|
207
|
+
style={{
|
|
208
|
+
gridTemplateColumns: columns.value,
|
|
209
|
+
columnGap: `${props.cellGap}px`,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{markers.value.map((m, i) => (
|
|
213
|
+
<span key={i}>{monthLabel(m)}</span>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* The grid itself: cells flow column-first into 7 rows. */}
|
|
218
|
+
<div
|
|
219
|
+
data-testid="hk-calendar-heatmap"
|
|
220
|
+
data-max={max.value}
|
|
221
|
+
role="img"
|
|
222
|
+
aria-label={fill(
|
|
223
|
+
t(
|
|
224
|
+
"hikari::calendarHeatmap.summary",
|
|
225
|
+
"Daily usage across {weeks} weeks",
|
|
226
|
+
),
|
|
227
|
+
{ weeks: weeks.value },
|
|
228
|
+
)}
|
|
229
|
+
class="hk-calendar-heatmap-grid"
|
|
230
|
+
style={{
|
|
231
|
+
gridTemplateColumns: columns.value,
|
|
232
|
+
gridTemplateRows: `repeat(7, ${props.cellSize}px)`,
|
|
233
|
+
gridAutoFlow: "column",
|
|
234
|
+
gap: `${props.cellGap}px`,
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{cells.value.map((cell, i) => (
|
|
238
|
+
<span
|
|
239
|
+
key={i}
|
|
240
|
+
{...(cell.kind === "gap"
|
|
241
|
+
? {
|
|
242
|
+
"data-gap": cell.phase,
|
|
243
|
+
"data-date": cell.date,
|
|
244
|
+
"aria-hidden": "true",
|
|
245
|
+
}
|
|
246
|
+
: {
|
|
247
|
+
"data-level": cell.level,
|
|
248
|
+
"data-date": cell.date,
|
|
249
|
+
title: cellTitle(cell),
|
|
250
|
+
})}
|
|
251
|
+
style={cellStyle(cell)}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
bucketLevel,
|
|
5
|
+
calendarGrid,
|
|
6
|
+
monthMarkers,
|
|
7
|
+
shiftIso,
|
|
8
|
+
todayIso,
|
|
9
|
+
weekdayIndex,
|
|
10
|
+
type HkCalendarHeatmapDay,
|
|
11
|
+
} from "./calendarHeatmap";
|
|
12
|
+
|
|
13
|
+
/** A Monday, for grid-shape assertions. */
|
|
14
|
+
const MON = "2025-12-01";
|
|
15
|
+
|
|
16
|
+
describe("weekdayIndex", () => {
|
|
17
|
+
it("rotates getUTCDay to Monday-first", () => {
|
|
18
|
+
expect(weekdayIndex("2025-12-01")).toBe(0); // Monday
|
|
19
|
+
expect(weekdayIndex("2025-12-07")).toBe(6); // Sunday
|
|
20
|
+
expect(weekdayIndex("2025-12-02")).toBe(1); // Tuesday
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("shiftIso", () => {
|
|
25
|
+
it("shifts in UTC without local-timezone leaks", () => {
|
|
26
|
+
expect(shiftIso("2025-12-01", 7)).toBe("2025-12-08");
|
|
27
|
+
expect(shiftIso("2025-12-01", -1)).toBe("2025-11-30");
|
|
28
|
+
expect(shiftIso("2026-01-01", -1)).toBe("2025-12-31"); // year wrap
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("todayIso", () => {
|
|
33
|
+
it("returns a UTC ISO date", () => {
|
|
34
|
+
expect(todayIso()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("bucketLevel", () => {
|
|
39
|
+
it("keeps zero and negative days at level 0", () => {
|
|
40
|
+
expect(bucketLevel(0, 10)).toBe(0);
|
|
41
|
+
expect(bucketLevel(-3, 10)).toBe(0);
|
|
42
|
+
expect(bucketLevel(5, 0)).toBe(0); // degenerate window
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("scales against the window max with a ceil floor of 1", () => {
|
|
46
|
+
expect(bucketLevel(1, 100)).toBe(1); // smallest consumption shows
|
|
47
|
+
expect(bucketLevel(50, 100)).toBe(2);
|
|
48
|
+
expect(bucketLevel(75, 100)).toBe(3);
|
|
49
|
+
expect(bucketLevel(100, 100)).toBe(4);
|
|
50
|
+
expect(bucketLevel(1000, 100)).toBe(4); // capped at peak
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("calendarGrid maxOverride — the max prop's wire to the buckets", () => {
|
|
55
|
+
it("pins the intensity scale against the override, not the window's own peak", () => {
|
|
56
|
+
const days = [{ date: MON, value: 10 }, { date: "2025-12-02", value: 1 }];
|
|
57
|
+
// Without an override the levels ride the window's own peak (10).
|
|
58
|
+
expect(calendarGrid(days, "2025-12-03", null).map((c) =>
|
|
59
|
+
c.kind === "day" ? c.level : -1,
|
|
60
|
+
)).toEqual([4, 1, 0, ...Array(4).fill(-1)]);
|
|
61
|
+
// With maxOverride=100 the same days flatten — the caller's pinned
|
|
62
|
+
// scale reaches the buckets (R2 S1: the prop was a dead wire).
|
|
63
|
+
expect(calendarGrid(days, "2025-12-03", null, 100).map((c) =>
|
|
64
|
+
c.kind === "day" ? c.level : -1,
|
|
65
|
+
)).toEqual([1, 1, 0, ...Array(4).fill(-1)]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("derives from the days when the override is null or omitted", () => {
|
|
69
|
+
const days = [{ date: MON, value: 5 }];
|
|
70
|
+
expect(calendarGrid(days, "2025-12-03", null, null)[0]).toMatchObject({
|
|
71
|
+
level: 4,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("calendarGrid", () => {
|
|
77
|
+
it("pads a one-row series into a full week with real dates", () => {
|
|
78
|
+
const cells = calendarGrid(
|
|
79
|
+
[{ date: MON, value: 5 }],
|
|
80
|
+
"2025-12-03",
|
|
81
|
+
null,
|
|
82
|
+
);
|
|
83
|
+
expect(cells).toHaveLength(7);
|
|
84
|
+
expect(cells[0]).toMatchObject({ kind: "day", date: MON });
|
|
85
|
+
// Today and after: the tail of the always-full-year window.
|
|
86
|
+
expect(cells[2]).toMatchObject({ kind: "day", date: "2025-12-03" });
|
|
87
|
+
expect(cells[3]).toMatchObject({ kind: "gap", date: "2025-12-04", phase: "after" });
|
|
88
|
+
expect(cells[6]).toMatchObject({ kind: "gap", phase: "after" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("hatches the before-first region and never claims a fake zero", () => {
|
|
92
|
+
const cells = calendarGrid(
|
|
93
|
+
[{ date: "2025-12-05", value: 5 }],
|
|
94
|
+
"2025-12-07",
|
|
95
|
+
"2025-12-05",
|
|
96
|
+
);
|
|
97
|
+
// 12-01..12-04: gaps (before); 12-05..12-07: days.
|
|
98
|
+
expect(cells[0]).toMatchObject({ kind: "gap", phase: "before" });
|
|
99
|
+
expect(cells[3]).toMatchObject({ kind: "gap", phase: "before" });
|
|
100
|
+
expect(cells[4]).toMatchObject({ kind: "day", date: "2025-12-05" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("counts head padding before the series' first row as plain empty days, never gaps", () => {
|
|
104
|
+
// The server didn't ship 12-01..12-02 (window starts 12-03) — but
|
|
105
|
+
// no `first` anchor exists, so they are empty days, not "not yet".
|
|
106
|
+
const cells = calendarGrid(
|
|
107
|
+
[{ date: "2025-12-03", value: 5 }],
|
|
108
|
+
"2025-12-03",
|
|
109
|
+
null,
|
|
110
|
+
);
|
|
111
|
+
expect(cells[0]).toMatchObject({ kind: "day", date: "2025-12-01", level: 0 });
|
|
112
|
+
expect(cells[1]).toMatchObject({ kind: "day", date: "2025-12-02", level: 0 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("clamps a malformed future `first` to hatch nothing", () => {
|
|
116
|
+
const cells = calendarGrid(
|
|
117
|
+
[{ date: MON, value: 5 }],
|
|
118
|
+
"2025-12-03",
|
|
119
|
+
"2026-01-01",
|
|
120
|
+
);
|
|
121
|
+
expect(cells.some((c) => c.kind === "gap" && c.phase === "before")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns an empty grid for an empty series", () => {
|
|
125
|
+
expect(calendarGrid([], "2025-12-03", null)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("monthMarkers — the leading-month suppression", () => {
|
|
130
|
+
it("shows the first marker only when the grid's first column opens its month", () => {
|
|
131
|
+
// Grid starts exactly on Dec 1 (a Monday AND the 1st); one row of
|
|
132
|
+
// days pads into exactly one week.
|
|
133
|
+
const cells = calendarGrid(
|
|
134
|
+
[{ date: MON, value: 0 }],
|
|
135
|
+
"2025-12-09",
|
|
136
|
+
null,
|
|
137
|
+
);
|
|
138
|
+
expect(cells).toHaveLength(7);
|
|
139
|
+
expect(cells[0]).toMatchObject({ date: "2025-12-01" });
|
|
140
|
+
expect(monthMarkers(cells)).toEqual(["12"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("suppresses a leading month that starts mid-month; the next month is labeled", () => {
|
|
144
|
+
// Grid starts Oct 27 (a Monday, NOT the 1st) and runs into November;
|
|
145
|
+
// two full weeks of cells.
|
|
146
|
+
const days: HkCalendarHeatmapDay[] = [
|
|
147
|
+
{ date: "2025-10-27", value: 0 },
|
|
148
|
+
{ date: "2025-10-28", value: 0 },
|
|
149
|
+
{ date: "2025-10-29", value: 0 },
|
|
150
|
+
{ date: "2025-10-30", value: 0 },
|
|
151
|
+
{ date: "2025-10-31", value: 0 },
|
|
152
|
+
{ date: "2025-11-01", value: 0 },
|
|
153
|
+
{ date: "2025-11-02", value: 0 },
|
|
154
|
+
{ date: "2025-11-03", value: 0 },
|
|
155
|
+
{ date: "2025-11-04", value: 0 },
|
|
156
|
+
{ date: "2025-11-05", value: 0 },
|
|
157
|
+
];
|
|
158
|
+
const cells = calendarGrid(days, "2025-11-05", null);
|
|
159
|
+
expect(cells).toHaveLength(14);
|
|
160
|
+
// The partial October tail is unlabeled; November's Monday opens.
|
|
161
|
+
expect(monthMarkers(cells)).toEqual(["", "11"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("labels no month when the whole grid sits inside one partial month", () => {
|
|
165
|
+
const cells = calendarGrid(
|
|
166
|
+
[{ date: "2025-10-13", value: 0 }],
|
|
167
|
+
"2025-10-20",
|
|
168
|
+
null,
|
|
169
|
+
);
|
|
170
|
+
expect(monthMarkers(cells)).toEqual([""]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps the later markers honest about consecutive same-month columns", () => {
|
|
174
|
+
// Two December Mondays: only the first carries the label.
|
|
175
|
+
const days: HkCalendarHeatmapDay[] = [
|
|
176
|
+
{ date: MON, value: 0 },
|
|
177
|
+
{ date: "2025-12-02", value: 0 },
|
|
178
|
+
{ date: "2025-12-03", value: 0 },
|
|
179
|
+
{ date: "2025-12-04", value: 0 },
|
|
180
|
+
{ date: "2025-12-05", value: 0 },
|
|
181
|
+
{ date: "2025-12-06", value: 0 },
|
|
182
|
+
{ date: "2025-12-07", value: 0 },
|
|
183
|
+
{ date: "2025-12-08", value: 0 },
|
|
184
|
+
{ date: "2025-12-09", value: 0 },
|
|
185
|
+
{ date: "2025-12-10", value: 0 },
|
|
186
|
+
];
|
|
187
|
+
const cells = calendarGrid(days, "2025-12-10", null);
|
|
188
|
+
expect(monthMarkers(cells)).toEqual(["12", ""]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar-heatmap math — pure helpers for HkCalendarHeatmap.
|
|
3
|
+
*
|
|
4
|
+
* Upstreamed from the shittim-chest quota page's degree-usage section
|
|
5
|
+
* (2026-10 user direction: a calendar heatmap is a big business
|
|
6
|
+
* component — the rich-text-viewer table — not a view-local helper).
|
|
7
|
+
* The server ships a DENSE daily series (oldest → newest, zero-filled,
|
|
8
|
+
* inclusive of today, plus the account's first activity date).
|
|
9
|
+
* Everything the component renders — the full-year week grid with its
|
|
10
|
+
* hatched no-data gaps, the month markers — derives here so each
|
|
11
|
+
* derivation is unit-testable without a component mount.
|
|
12
|
+
*/
|
|
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;
|
|
19
|
+
|
|
20
|
+
/** One input day of the dense series. `value` may be negative or zero
|
|
21
|
+
* (a "no consumption" day) — it still buckets to level 0. */
|
|
22
|
+
export type HkCalendarHeatmapDay = { date: string; value: number };
|
|
23
|
+
|
|
24
|
+
/** Monday-first weekday index of an ISO date (Mon = 0 … Sun = 6). */
|
|
25
|
+
export function weekdayIndex(iso: string): number {
|
|
26
|
+
const d = new Date(`${iso}T00:00:00Z`);
|
|
27
|
+
// getUTCDay: Sun = 0 … Sat = 6 → rotate to Monday-first.
|
|
28
|
+
return (d.getUTCDay() + 6) % 7;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** ISO date ± n days (UTC arithmetic only — no local timezone leaks). */
|
|
32
|
+
export function shiftIso(iso: string, days: number): string {
|
|
33
|
+
const d = new Date(`${iso}T00:00:00Z`);
|
|
34
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
35
|
+
return d.toISOString().slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Today as an ISO date (UTC — the series' day boundary). */
|
|
39
|
+
export function todayIso(): string {
|
|
40
|
+
return new Date().toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One heatmap cell: a real day of the series, or a gap the account has
|
|
45
|
+
* no data for — before its first activity, or after today (the rest of
|
|
46
|
+
* the always-full-year window). Gaps render hatched and inert; they
|
|
47
|
+
* never claim a fake "0 consumption" for a day that never was.
|
|
48
|
+
*/
|
|
49
|
+
export type CalendarCell =
|
|
50
|
+
| { kind: "day"; date: string; value: number; level: 0 | 1 | 2 | 3 | 4 }
|
|
51
|
+
| { kind: "gap"; date: string; phase: "before" | "after" };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pad the dense series into the week-aligned calendar grid: head cells
|
|
55
|
+
* back to the window's first Monday, tail cells to Sunday — so a
|
|
56
|
+
* trailing-year window ALWAYS lands on a full-year-wide grid, however
|
|
57
|
+
* young the account is. Every cell carries its real calendar date;
|
|
58
|
+
* cells outside [first … today] become gaps (hatched, inert).
|
|
59
|
+
*
|
|
60
|
+
* `maxOverride` pins the intensity scale (R2: the component's `max`
|
|
61
|
+
* prop must reach the buckets, not just the data-max attribute — a
|
|
62
|
+
* caller pinning the scale across windows would otherwise get levels
|
|
63
|
+
* computed against each window's own peak). null = derive from `days`.
|
|
64
|
+
*/
|
|
65
|
+
export function calendarGrid(
|
|
66
|
+
days: HkCalendarHeatmapDay[],
|
|
67
|
+
today: string,
|
|
68
|
+
first: string | null,
|
|
69
|
+
maxOverride?: number | null,
|
|
70
|
+
): CalendarCell[] {
|
|
71
|
+
if (days.length === 0) return [];
|
|
72
|
+
const lead = weekdayIndex(days[0].date);
|
|
73
|
+
const gridStart = shiftIso(days[0].date, -lead);
|
|
74
|
+
const total = lead + days.length;
|
|
75
|
+
const span = total + ((7 - (total % 7)) % 7);
|
|
76
|
+
const max = maxOverride ?? days.reduce((m, d) => Math.max(m, d.value), 0);
|
|
77
|
+
const valueByDate = new Map(days.map((d) => [d.date, d.value]));
|
|
78
|
+
// A malformed `first` from the future hatches nothing (the clamp
|
|
79
|
+
// keeps the before-check honest).
|
|
80
|
+
const hatchFrom = first && first <= today ? first : null;
|
|
81
|
+
const cells: CalendarCell[] = [];
|
|
82
|
+
for (let i = 0; i < span; i++) {
|
|
83
|
+
const date = shiftIso(gridStart, i);
|
|
84
|
+
if (date > today) {
|
|
85
|
+
cells.push({ kind: "gap", date, phase: "after" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (hatchFrom && date < hatchFrom) {
|
|
89
|
+
cells.push({ kind: "gap", date, phase: "before" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Dates in the head padding before the series' first row predate
|
|
93
|
+
// the requested window (the server didn't ship them) — they count
|
|
94
|
+
// as plain empty days, never gaps.
|
|
95
|
+
const value = valueByDate.get(date) ?? 0;
|
|
96
|
+
cells.push({ kind: "day", date, value, level: bucketLevel(value, max) });
|
|
97
|
+
}
|
|
98
|
+
return cells;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The heatmap's intensity bucket: 0 (none) … 4 (peak), scaled against
|
|
102
|
+
* the window's max day. Zero/negative days are bucket 0; every
|
|
103
|
+
* consuming day lands in 1..4 (ceil, so even the smallest consumption
|
|
104
|
+
* shows one step above empty). */
|
|
105
|
+
export function bucketLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {
|
|
106
|
+
if (value <= 0 || max <= 0) return 0;
|
|
107
|
+
return Math.min(4, Math.max(1, Math.ceil((value / max) * 4))) as 0 | 1 | 2 | 3 | 4;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Month markers for the week columns: a short label on each column
|
|
112
|
+
* whose Monday opens a month, "" otherwise.
|
|
113
|
+
*
|
|
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.
|
|
124
|
+
*/
|
|
125
|
+
export function monthMarkers(cells: CalendarCell[]): string[] {
|
|
126
|
+
const weeks = Math.ceil(cells.length / 7);
|
|
127
|
+
const markers: string[] = [];
|
|
128
|
+
let prevMonth = -1;
|
|
129
|
+
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;
|
|
137
|
+
}
|
|
138
|
+
return markers;
|
|
139
|
+
}
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "أقل من",
|
|
218
218
|
"hikari::filterPanel.op.lte": "كحد أقصى",
|
|
219
219
|
"hikari::filterPanel.op.gte": "على الأقل",
|
|
220
|
-
"hikari::filterPanel.op.gt": "أكبر من"
|
|
220
|
+
"hikari::filterPanel.op.gt": "أكبر من",
|
|
221
|
+
"hikari::calendarHeatmap.less": "أقل",
|
|
222
|
+
"hikari::calendarHeatmap.more": "أكثر",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "الاستخدام اليومي على مدى {weeks} أسبوعًا"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "غير محدد",
|
|
223
228
|
"hikari::affixPicker.title": "الخيارات",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "kleiner als",
|
|
218
218
|
"hikari::filterPanel.op.lte": "höchstens",
|
|
219
219
|
"hikari::filterPanel.op.gte": "mindestens",
|
|
220
|
-
"hikari::filterPanel.op.gt": "größer als"
|
|
220
|
+
"hikari::filterPanel.op.gt": "größer als",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Weniger",
|
|
222
|
+
"hikari::calendarHeatmap.more": "Mehr",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Tägliche Nutzung über {weeks} Wochen"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "Nicht festgelegt",
|
|
223
228
|
"hikari::affixPicker.title": "Optionen",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "less than",
|
|
218
218
|
"hikari::filterPanel.op.lte": "at most",
|
|
219
219
|
"hikari::filterPanel.op.gte": "at least",
|
|
220
|
-
"hikari::filterPanel.op.gt": "greater than"
|
|
220
|
+
"hikari::filterPanel.op.gt": "greater than",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Less",
|
|
222
|
+
"hikari::calendarHeatmap.more": "More",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Daily usage across {weeks} weeks"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.title": "Options",
|
|
223
228
|
"hikari::affixPicker.search": "Search",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "menor que",
|
|
218
218
|
"hikari::filterPanel.op.lte": "como máximo",
|
|
219
219
|
"hikari::filterPanel.op.gte": "al menos",
|
|
220
|
-
"hikari::filterPanel.op.gt": "mayor que"
|
|
220
|
+
"hikari::filterPanel.op.gt": "mayor que",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Menos",
|
|
222
|
+
"hikari::calendarHeatmap.more": "Más",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Uso diario durante {weeks} semanas"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "Sin establecer",
|
|
223
228
|
"hikari::affixPicker.title": "Opciones",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "inférieur à",
|
|
218
218
|
"hikari::filterPanel.op.lte": "au plus",
|
|
219
219
|
"hikari::filterPanel.op.gte": "au moins",
|
|
220
|
-
"hikari::filterPanel.op.gt": "supérieur à"
|
|
220
|
+
"hikari::filterPanel.op.gt": "supérieur à",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Moins",
|
|
222
|
+
"hikari::calendarHeatmap.more": "Plus",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Utilisation quotidienne sur {weeks} semaines"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "Non défini",
|
|
223
228
|
"hikari::affixPicker.title": "Options",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "未満",
|
|
218
218
|
"hikari::filterPanel.op.lte": "以下",
|
|
219
219
|
"hikari::filterPanel.op.gte": "以上",
|
|
220
|
-
"hikari::filterPanel.op.gt": "より大きい"
|
|
220
|
+
"hikari::filterPanel.op.gt": "より大きい",
|
|
221
|
+
"hikari::calendarHeatmap.less": "少",
|
|
222
|
+
"hikari::calendarHeatmap.more": "多",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "直近 {weeks} 週間の毎日の使用量"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "未設定",
|
|
223
228
|
"hikari::affixPicker.title": "オプション",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "미만",
|
|
218
218
|
"hikari::filterPanel.op.lte": "이하",
|
|
219
219
|
"hikari::filterPanel.op.gte": "이상",
|
|
220
|
-
"hikari::filterPanel.op.gt": "초과"
|
|
220
|
+
"hikari::filterPanel.op.gt": "초과",
|
|
221
|
+
"hikari::calendarHeatmap.less": "적음",
|
|
222
|
+
"hikari::calendarHeatmap.more": "많음",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "최근 {weeks}주간의 일일 사용량"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "미설정",
|
|
223
228
|
"hikari::affixPicker.title": "옵션",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "menor que",
|
|
218
218
|
"hikari::filterPanel.op.lte": "no máximo",
|
|
219
219
|
"hikari::filterPanel.op.gte": "pelo menos",
|
|
220
|
-
"hikari::filterPanel.op.gt": "maior que"
|
|
220
|
+
"hikari::filterPanel.op.gt": "maior que",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Menos",
|
|
222
|
+
"hikari::calendarHeatmap.more": "Mais",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Uso diário ao longo de {weeks} semanas"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "Não definido",
|
|
223
228
|
"hikari::affixPicker.title": "Opções",
|
|
@@ -217,7 +217,12 @@
|
|
|
217
217
|
"hikari::filterPanel.op.lt": "меньше",
|
|
218
218
|
"hikari::filterPanel.op.lte": "не более",
|
|
219
219
|
"hikari::filterPanel.op.gte": "не менее",
|
|
220
|
-
"hikari::filterPanel.op.gt": "больше"
|
|
220
|
+
"hikari::filterPanel.op.gt": "больше",
|
|
221
|
+
"hikari::calendarHeatmap.less": "Меньше",
|
|
222
|
+
"hikari::calendarHeatmap.more": "Больше",
|
|
223
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
224
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
225
|
+
"hikari::calendarHeatmap.summary": "Ежедневное использование за {weeks} недель"
|
|
221
226
|
},
|
|
222
227
|
"hikari::affixPicker.unset": "Не задано",
|
|
223
228
|
"hikari::affixPicker.title": "Параметры",
|
|
@@ -221,7 +221,12 @@
|
|
|
221
221
|
"hikari::filterPanel.op.lt": "小于",
|
|
222
222
|
"hikari::filterPanel.op.lte": "不超过",
|
|
223
223
|
"hikari::filterPanel.op.gte": "不低于",
|
|
224
|
-
"hikari::filterPanel.op.gt": "大于"
|
|
224
|
+
"hikari::filterPanel.op.gt": "大于",
|
|
225
|
+
"hikari::calendarHeatmap.less": "少",
|
|
226
|
+
"hikari::calendarHeatmap.more": "多",
|
|
227
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
228
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
229
|
+
"hikari::calendarHeatmap.summary": "近 {weeks} 周的每日用量"
|
|
225
230
|
},
|
|
226
231
|
"hikari::affixPicker.title": "选项",
|
|
227
232
|
"hikari::affixPicker.search": "搜索",
|
|
@@ -221,7 +221,12 @@
|
|
|
221
221
|
"hikari::filterPanel.op.lt": "小於",
|
|
222
222
|
"hikari::filterPanel.op.lte": "不超過",
|
|
223
223
|
"hikari::filterPanel.op.gte": "不低於",
|
|
224
|
-
"hikari::filterPanel.op.gt": "大於"
|
|
224
|
+
"hikari::filterPanel.op.gt": "大於",
|
|
225
|
+
"hikari::calendarHeatmap.less": "少",
|
|
226
|
+
"hikari::calendarHeatmap.more": "多",
|
|
227
|
+
"hikari::calendarHeatmap.cellTitle": "{date} · {n} {unit}",
|
|
228
|
+
"hikari::calendarHeatmap.cellTitleNoUnit": "{date} · {n}",
|
|
229
|
+
"hikari::calendarHeatmap.summary": "近 {weeks} 週的每日用量"
|
|
225
230
|
},
|
|
226
231
|
"hikari::affixPicker.title": "選項",
|
|
227
232
|
"hikari::affixPicker.search": "搜尋",
|
package/src/index.ts
CHANGED
|
@@ -644,6 +644,20 @@ export { default as HkDivider } from "./components/HkDivider";
|
|
|
644
644
|
export { default as HkDiffViewer } from "./components/HkDiffViewer";
|
|
645
645
|
export { parseUnifiedDiff, computeGaps } from "./components/diffParse";
|
|
646
646
|
export type { DiffLine, DiffHunk, ParsedDiff, UnchangedGap } from "./components/diffParse";
|
|
647
|
+
export { default as HkCalendarHeatmap } from "./components/HkCalendarHeatmap";
|
|
648
|
+
// The H- alias rides the HCard/HkCard family convention: consumers
|
|
649
|
+
// import either name, one component underneath.
|
|
650
|
+
export { default as HCalendarHeatmap } from "./components/HkCalendarHeatmap";
|
|
651
|
+
export {
|
|
652
|
+
bucketLevel,
|
|
653
|
+
calendarGrid,
|
|
654
|
+
HEATMAP_LEVEL_OPACITY,
|
|
655
|
+
monthMarkers,
|
|
656
|
+
todayIso,
|
|
657
|
+
weekdayIndex,
|
|
658
|
+
shiftIso,
|
|
659
|
+
} from "./components/calendarHeatmap";
|
|
660
|
+
export type { CalendarCell, HkCalendarHeatmapDay } from "./components/calendarHeatmap";
|
|
647
661
|
export { default as HkDockBar } from "./components/HkDockBar";
|
|
648
662
|
export { default as HkDrawer } from "./components/HkDrawer";
|
|
649
663
|
export { default as HkEmptyState } from "./components/HkEmptyState";
|
|
@@ -574,6 +574,16 @@
|
|
|
574
574
|
}
|
|
575
575
|
|
|
576
576
|
.s-admin-header-avatar {
|
|
577
|
+
/* Block, never the UA's inline-block: hosts may nest the trigger in a
|
|
578
|
+
* plain block wrapper (the chat face's menu-anchor <div> around
|
|
579
|
+
* HeaderLeft's HkAvatarButton), and an inline-block button there joins
|
|
580
|
+
* a line box — its baseline (the bottom margin edge, because of
|
|
581
|
+
* `overflow: hidden`) sits on the strut's baseline, so the strut's
|
|
582
|
+
* descender space lands BELOW the avatar and the button rides ~4px
|
|
583
|
+
* high inside a perfectly centred wrapper (2026-10-30 user report:
|
|
584
|
+
* 前台头像略微偏上,后台正中). A direct flex/grid parent blockifies
|
|
585
|
+
* this away, so the console face is unaffected either way. */
|
|
586
|
+
display: block;
|
|
577
587
|
position: relative;
|
|
578
588
|
width: var(--space-28);
|
|
579
589
|
height: var(--space-28);
|