@celestia-island/hikari 0.55.330 → 0.55.332
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 +10 -10
- package/src/components/HkCalendarHeatmap.scss +11 -0
- package/src/components/HkCalendarHeatmap.test.tsx +214 -5
- package/src/components/HkCalendarHeatmap.tsx +246 -10
- package/src/components/HkFilterChip.scss +15 -0
- package/src/components/HkFilterPanel.scss +6 -0
- package/src/components/HkFilterTrigger.scss +7 -0
- package/src/components/calendarHeatmap.test.ts +5 -5
- package/src/components/calendarHeatmap.ts +14 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.332",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
|
|
@@ -62,23 +62,23 @@
|
|
|
62
62
|
"prepublishOnly": "vue-tsc --noEmit"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@types/three": "
|
|
65
|
+
"@types/three": "^0.185",
|
|
66
66
|
"three": "^0.185",
|
|
67
|
-
"vue": "
|
|
67
|
+
"vue": "^3"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@celestia-island/tairitsu-transport": "^0.2
|
|
71
|
-
"@xterm/addon-fit": "^0
|
|
70
|
+
"@celestia-island/tairitsu-transport": "^0.2",
|
|
71
|
+
"@xterm/addon-fit": "^0.11",
|
|
72
72
|
"@xterm/addon-image": "^0.10.0-beta.301",
|
|
73
73
|
"@xterm/xterm": "^6.1.0-beta.304",
|
|
74
|
-
"dompurify": "
|
|
75
|
-
"highlight.js": "
|
|
76
|
-
"lucide-vue-next": "
|
|
77
|
-
"marked": "
|
|
74
|
+
"dompurify": "^3",
|
|
75
|
+
"highlight.js": "^11",
|
|
76
|
+
"lucide-vue-next": "^1",
|
|
77
|
+
"marked": "^18"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/node": "^*",
|
|
81
|
-
"@types/three": "
|
|
81
|
+
"@types/three": "^0.185",
|
|
82
82
|
"@vitejs/plugin-vue": "^*",
|
|
83
83
|
"@vitejs/plugin-vue-jsx": "^*",
|
|
84
84
|
"@vue/server-renderer": "^*",
|
|
@@ -131,4 +131,15 @@
|
|
|
131
131
|
&-grid {
|
|
132
132
|
display: grid;
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
// The shared cell tooltip's content area. The popup chrome (bubble,
|
|
136
|
+
// shadow, placement geometry) is HkTooltip's .hk-tooltip-popup — the
|
|
137
|
+
// heatmap only frames the caller's slot content, usually one row per
|
|
138
|
+
// model with the metering readout on the right.
|
|
139
|
+
&-tip {
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
gap: 4px;
|
|
143
|
+
min-width: 140px;
|
|
144
|
+
}
|
|
134
145
|
}
|
|
@@ -5,8 +5,17 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
7
7
|
|
|
8
|
-
import HkCalendarHeatmap
|
|
9
|
-
|
|
8
|
+
import HkCalendarHeatmap, {
|
|
9
|
+
HEATMAP_GAP_FILL,
|
|
10
|
+
TOOLTIP_DELAY_MS,
|
|
11
|
+
} from "./HkCalendarHeatmap";
|
|
12
|
+
import { HEATMAP_EMPTY_FILL, HEATMAP_LEVEL_OPACITY, shiftIso, type CalendarCell } from "./calendarHeatmap";
|
|
13
|
+
|
|
14
|
+
/** The shared hover popup's show delay rides the component's EXPORTED
|
|
15
|
+
* constant (R5: the old duplicated literal let a shorter delay ship
|
|
16
|
+
* green — the tests must DERIVE, not restate). The behavioral pin
|
|
17
|
+
* lives in the hover test: hidden at delay−1, visible at delay. */
|
|
18
|
+
expect(HEATMAP_GAP_FILL).not.toContain("gradient");
|
|
10
19
|
|
|
11
20
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
21
|
|
|
@@ -237,9 +246,9 @@ describe("HkCalendarHeatmap", () => {
|
|
|
237
246
|
expect(cellAt("0").style.opacity).toBe("");
|
|
238
247
|
});
|
|
239
248
|
|
|
240
|
-
it("renders gap cells
|
|
249
|
+
it("renders gap cells dimmed below the empty level, with no level claim", () => {
|
|
241
250
|
// A one-day series AT today, anchored at first = today: the head
|
|
242
|
-
// padding
|
|
251
|
+
// padding dims as "before", the tail as "after". The clock is
|
|
243
252
|
// PINNED to a Wednesday: with the real clock this test carried
|
|
244
253
|
// both-phase expectations into a Monday (no head padding) or a
|
|
245
254
|
// Sunday (no tail), so it went red two days out of seven
|
|
@@ -266,13 +275,213 @@ describe("HkCalendarHeatmap", () => {
|
|
|
266
275
|
for (const gap of gaps) {
|
|
267
276
|
expect(gap.getAttribute("aria-hidden")).toBe("true");
|
|
268
277
|
expect(gap.getAttribute("title")).toBeNull();
|
|
269
|
-
|
|
278
|
+
// The 2026-11 direction: the diagonal hatch is GONE — a gap
|
|
279
|
+
// claims no gradient, no level, no primary ink. (The fill's
|
|
280
|
+
// VALUE rides HEATMAP_GAP_FILL — happy-dom drops color-mix()
|
|
281
|
+
// from a parsed background, so the constant itself is pinned
|
|
282
|
+
// at this file's head and by the source contract below.)
|
|
283
|
+
expect(gap.getAttribute("style") ?? "").not.toContain("gradient");
|
|
284
|
+
expect(gap.getAttribute("style") ?? "").not.toContain("--hi-color-primary");
|
|
285
|
+
expect(gap.getAttribute("data-level")).toBeNull();
|
|
286
|
+
// The ladder must read gap < empty < data (R2 2026-11): the
|
|
287
|
+
// dimmed wash sits BELOW the empty level's wash, parsed from
|
|
288
|
+
// the two exported constants so a percentage drift on either
|
|
289
|
+
// side flips this even with every other gate green.
|
|
290
|
+
const pct = (fill: string) =>
|
|
291
|
+
Number(/(\d+)%/.exec(fill)?.[1] ?? NaN);
|
|
292
|
+
expect(pct(HEATMAP_GAP_FILL)).toBeLessThan(pct(HEATMAP_EMPTY_FILL));
|
|
270
293
|
}
|
|
271
294
|
} finally {
|
|
272
295
|
vi.useRealTimers();
|
|
273
296
|
}
|
|
274
297
|
});
|
|
275
298
|
|
|
299
|
+
it("paints gap cells with HEATMAP_GAP_FILL, not a hatch (source contract)", () => {
|
|
300
|
+
// happy-dom cannot observe the gap fill through a parsed style, so
|
|
301
|
+
// this pin reads the sheet's wiring: the gap branch of cellStyle
|
|
302
|
+
// must carry the exported dimmed-wash constant — a re-introduced
|
|
303
|
+
// repeating-linear-gradient hatch (or a hand-written fill) turns
|
|
304
|
+
// this red.
|
|
305
|
+
const sheet = readFileSync(join(here, "HkCalendarHeatmap.tsx"), "utf8");
|
|
306
|
+
expect(sheet).not.toContain("repeating-linear-gradient");
|
|
307
|
+
expect(sheet).toContain("background: HEATMAP_GAP_FILL");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("without a tooltip slot, day cells keep the native title and no popup ever mounts", async () => {
|
|
311
|
+
vi.useFakeTimers();
|
|
312
|
+
try {
|
|
313
|
+
const c = mount(
|
|
314
|
+
h(HkCalendarHeatmap, {
|
|
315
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
316
|
+
first: null,
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
320
|
+
expect(cell.getAttribute("title")).toContain("Dec 1, 2025");
|
|
321
|
+
cell.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
322
|
+
await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS);
|
|
323
|
+
await nextTick();
|
|
324
|
+
expect(document.querySelector(".hk-calendar-heatmap-tip")).toBeNull();
|
|
325
|
+
// The old contract stands in full: the gaps stay INERT (R2 2026-11
|
|
326
|
+
// restore — the dimmed wash is presentational, and without a slot
|
|
327
|
+
// nothing can ever engage them). The 5-day fixture spans a whole
|
|
328
|
+
// week, so a guaranteed-gap mount (one day + first anchor) pins it.
|
|
329
|
+
const inert = mount(
|
|
330
|
+
h(HkCalendarHeatmap, {
|
|
331
|
+
days: [{ date: "2025-12-03", value: 5 }],
|
|
332
|
+
first: "2025-12-03",
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
const gap = inert.querySelector("[data-gap]") as HTMLElement;
|
|
336
|
+
expect(gap.style.pointerEvents).toBe("none");
|
|
337
|
+
} finally {
|
|
338
|
+
vi.useRealTimers();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("with a tooltip slot, hovering a day cell opens the shared popup with the caller's content", async () => {
|
|
343
|
+
vi.useFakeTimers();
|
|
344
|
+
try {
|
|
345
|
+
const c = mount(
|
|
346
|
+
h(HkCalendarHeatmap, {
|
|
347
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
348
|
+
first: null,
|
|
349
|
+
}, {
|
|
350
|
+
tooltip: ({ cell }: { cell: CalendarCell }) =>
|
|
351
|
+
h("div", { "data-tip-for": cell.date }, `tip:${cell.kind}`),
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
// The slot owns the explanation: no native title (it would double
|
|
355
|
+
// up under the popup).
|
|
356
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
357
|
+
expect(cell.getAttribute("title")).toBeNull();
|
|
358
|
+
|
|
359
|
+
// Lazy: nothing is teleported until the first engage.
|
|
360
|
+
expect(document.querySelector(".hk-calendar-heatmap-tip")).toBeNull();
|
|
361
|
+
cell.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
362
|
+
// Behavioral lower bound (R5): the popup is NOT visible one
|
|
363
|
+
// millisecond before the delay — the tests derive the window
|
|
364
|
+
// from the exported constant, so a shorter delay ships red.
|
|
365
|
+
await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS - 1);
|
|
366
|
+
await nextTick();
|
|
367
|
+
const early = document.querySelector(
|
|
368
|
+
".hk-calendar-heatmap-tip.hk-tooltip-visible",
|
|
369
|
+
);
|
|
370
|
+
expect(early, "no visible popup before the delay elapses").toBeNull();
|
|
371
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
372
|
+
await nextTick();
|
|
373
|
+
const popup = document.querySelector(
|
|
374
|
+
".hk-calendar-heatmap-tip",
|
|
375
|
+
) as HTMLElement | null;
|
|
376
|
+
expect(popup, "the shared popup is up after the delay").not.toBeNull();
|
|
377
|
+
expect(popup!.getAttribute("role")).toBe("tooltip");
|
|
378
|
+
expect(popup!.textContent).toBe("tip:day");
|
|
379
|
+
expect(popup!.querySelector('[data-tip-for="2025-12-01"]')).not.toBeNull();
|
|
380
|
+
expect(popup!.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
381
|
+
|
|
382
|
+
// Leaving the grid hides it (the popup element stays mounted —
|
|
383
|
+
// lazy on FIRST engage, not per hover).
|
|
384
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
385
|
+
grid.dispatchEvent(new Event("pointerleave", { bubbles: false }));
|
|
386
|
+
await nextTick();
|
|
387
|
+
expect(
|
|
388
|
+
document.querySelector(".hk-calendar-heatmap-tip")!.classList.contains("hk-tooltip-visible"),
|
|
389
|
+
).toBe(false);
|
|
390
|
+
} finally {
|
|
391
|
+
vi.useRealTimers();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("the tooltip slot also fires for gap cells — the caller explains 'no data'", async () => {
|
|
396
|
+
vi.useFakeTimers();
|
|
397
|
+
try {
|
|
398
|
+
vi.setSystemTime(new Date("2025-12-03T10:00:00Z")); // a Wednesday
|
|
399
|
+
const c = mount(
|
|
400
|
+
h(HkCalendarHeatmap, {
|
|
401
|
+
days: [{ date: "2025-12-03", value: 5 }],
|
|
402
|
+
first: "2025-12-03",
|
|
403
|
+
}, {
|
|
404
|
+
tooltip: ({ cell }: { cell: CalendarCell }) =>
|
|
405
|
+
h("div", { "data-tip-kind": cell.kind }, JSON.stringify(cell)),
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
409
|
+
const gap = grid.querySelector("[data-gap]") as HTMLElement;
|
|
410
|
+
// With the slot the gaps are the popup's engage surface: they
|
|
411
|
+
// must stay hoverable (synthetic events bypass hit-testing, so
|
|
412
|
+
// only this pin catches a regression to unconditional none).
|
|
413
|
+
expect(gap.style.pointerEvents).not.toBe("none");
|
|
414
|
+
gap.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
415
|
+
await vi.advanceTimersByTimeAsync(TOOLTIP_DELAY_MS);
|
|
416
|
+
await nextTick();
|
|
417
|
+
const popup = document.querySelector(".hk-calendar-heatmap-tip") as HTMLElement;
|
|
418
|
+
const payload = JSON.parse(
|
|
419
|
+
popup.querySelector("[data-tip-kind]")!.textContent ?? "{}",
|
|
420
|
+
);
|
|
421
|
+
expect(payload).toMatchObject({ kind: "gap", date: gap.getAttribute("data-date") });
|
|
422
|
+
} finally {
|
|
423
|
+
vi.useRealTimers();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("a touch pointerleave does not close what a tap opened; a desktop leave does", async () => {
|
|
428
|
+
// Browsers fire the boundary-release chain (pointerleave on the
|
|
429
|
+
// tapped cell) right after a touch pointerup — implicit capture
|
|
430
|
+
// release. The tap-toggle owns touch dismissal, so that synthetic
|
|
431
|
+
// leave must NOT hide the popup (R2 2026-11; the mouse leave path
|
|
432
|
+
// stays). happy-dom does not model the implicit release, which is
|
|
433
|
+
// exactly why this needs its own pin.
|
|
434
|
+
vi.useFakeTimers();
|
|
435
|
+
try {
|
|
436
|
+
const c = mount(
|
|
437
|
+
h(HkCalendarHeatmap, {
|
|
438
|
+
days: daysFrom(CLEAN_HEAD_MON, 1, 5),
|
|
439
|
+
first: null,
|
|
440
|
+
}, {
|
|
441
|
+
tooltip: ({ cell }: { cell: CalendarCell }) =>
|
|
442
|
+
h("div", { "data-tip-for": cell.date }, `tip:${cell.kind}`),
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
const cell = c.querySelector('[data-level]:not([data-level="0"])') as HTMLElement;
|
|
446
|
+
const grid = c.querySelector('[data-testid="hk-calendar-heatmap"]') as HTMLElement;
|
|
447
|
+
|
|
448
|
+
// A touch tap arms + shows at once.
|
|
449
|
+
cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
450
|
+
await nextTick();
|
|
451
|
+
const popup = () => document.querySelector(".hk-calendar-heatmap-tip") as HTMLElement | null;
|
|
452
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
453
|
+
|
|
454
|
+
// The implicit-release leave: same gesture, must not hide.
|
|
455
|
+
grid.dispatchEvent(new PointerEvent("pointerleave", { pointerType: "touch" }));
|
|
456
|
+
await nextTick();
|
|
457
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
458
|
+
|
|
459
|
+
// A mouse (pointerType-less event reads undefined) leave hides.
|
|
460
|
+
grid.dispatchEvent(new PointerEvent("pointerleave", {}));
|
|
461
|
+
await nextTick();
|
|
462
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
|
|
463
|
+
|
|
464
|
+
// The tap-toggle owns touch dismissal, so BOTH directions stay
|
|
465
|
+
// pinned: a second tap on the SAME cell closes it…
|
|
466
|
+
cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
467
|
+
await nextTick();
|
|
468
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
469
|
+
cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
470
|
+
await nextTick();
|
|
471
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
|
|
472
|
+
|
|
473
|
+
// …and a tap OUTSIDE the grid dismisses a re-opened tooltip.
|
|
474
|
+
cell.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
475
|
+
await nextTick();
|
|
476
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(true);
|
|
477
|
+
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
478
|
+
await nextTick();
|
|
479
|
+
expect(popup()!.classList.contains("hk-tooltip-visible")).toBe(false);
|
|
480
|
+
} finally {
|
|
481
|
+
vi.useRealTimers();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
276
485
|
it("tooltips day cells with the unit when one is given", () => {
|
|
277
486
|
const c = mount(
|
|
278
487
|
h(HkCalendarHeatmap, {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, watch, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, ref, Teleport, watch, type PropType, type SlotsType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
|
+
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
5
|
+
import { applyTooltipPosition } from "../runtime/tooltipPosition";
|
|
6
|
+
import { ensureOverlayRoot } from "../overlay/overlayRoot";
|
|
4
7
|
import HkScrollContainer from "./HkScrollContainer";
|
|
5
8
|
import {
|
|
6
9
|
calendarGrid,
|
|
@@ -20,10 +23,23 @@ import "./HkCalendarHeatmap.scss";
|
|
|
20
23
|
const LEGEND_LEVELS = [0, 1, 2, 3, 4] as const;
|
|
21
24
|
|
|
22
25
|
/** The no-data cell (before the account's first activity / after
|
|
23
|
-
* today):
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
* today): a quiet, recessed wash — the muted token at a fainter
|
|
27
|
+
* strength than the EMPTY level's wash (40%), so the visual ladder
|
|
28
|
+
* reads gap < empty < data with no third channel. (2026-11 user
|
|
29
|
+
* direction: the old diagonal hatch read as noise at a year-wide
|
|
30
|
+
* grid's density — a flat dimmer cell carries the same "no data yet"
|
|
31
|
+
* claim without the stripes.) Deliberately NOT pointer-inert: with a
|
|
32
|
+
* `tooltip` slot the caller also explains gaps ("the account has no
|
|
33
|
+
* data for this date"), so cells stay hover targets. */
|
|
34
|
+
export const HEATMAP_GAP_FILL =
|
|
35
|
+
"color-mix(in srgb, var(--hi-color-muted) 20%, transparent)";
|
|
36
|
+
|
|
37
|
+
/** The hover tooltip's show delay — HkTooltip's default, so the two
|
|
38
|
+
* hover surfaces in a page share one cadence. */
|
|
39
|
+
/** The hover tooltip's show delay — HkTooltip's default, so the two
|
|
40
|
+
* hover surfaces in a page share one cadence. Exported so consumers
|
|
41
|
+
* (and the tests) derive from it instead of duplicating the literal. */
|
|
42
|
+
export const TOOLTIP_DELAY_MS = 300;
|
|
27
43
|
|
|
28
44
|
/** Minimal {name} interpolation for the component's i18n strings — the
|
|
29
45
|
* hikari context serves plain strings (no ICU), the placeholders stay
|
|
@@ -32,6 +48,12 @@ function fill(template: string, vars: Record<string, string | number>): string {
|
|
|
32
48
|
return template.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ""));
|
|
33
49
|
}
|
|
34
50
|
|
|
51
|
+
/** Stable per-instance popup id. The popup carries role="tooltip" and
|
|
52
|
+
* this id; callers' slot content that wants an aria-describedby link
|
|
53
|
+
* can reference it, but the component binds no describedby itself —
|
|
54
|
+
* the armed cell is a delegated span, not a managed wrapper. */
|
|
55
|
+
let popupSeq = 0;
|
|
56
|
+
|
|
35
57
|
/**
|
|
36
58
|
* HkCalendarHeatmap — a GitHub-style calendar heatmap of daily values.
|
|
37
59
|
*
|
|
@@ -40,7 +62,7 @@ function fill(template: string, vars: Record<string, string | number>): string {
|
|
|
40
62
|
* component — the rich-text-viewer table — and the leading-month
|
|
41
63
|
* marker refinement belongs in the library, not the consumer). A
|
|
42
64
|
* legend (less → more over the primary token), a Monday-first weekday
|
|
43
|
-
* rail, month markers, and
|
|
65
|
+
* rail, month markers, and dimmed no-data gap cells that never claim
|
|
44
66
|
* a fake zero.
|
|
45
67
|
*
|
|
46
68
|
* The leading month is never mislabeled: a month is labeled on the
|
|
@@ -55,6 +77,15 @@ function fill(template: string, vars: Record<string, string | number>): string {
|
|
|
55
77
|
* its own tests). The `toolbar` slot rides right of the legend — the
|
|
56
78
|
* consumer's range tabs / controls belong there.
|
|
57
79
|
*
|
|
80
|
+
* Tooltips (2026-11 user direction): every cell — day AND gap — carries
|
|
81
|
+
* a caller-decided tooltip through the `tooltip` slot (payload
|
|
82
|
+
* `{ cell }`, the same `CalendarCell` the grid renders). The component
|
|
83
|
+
* owns the machinery (one lazy, teleported hover popup in the shared
|
|
84
|
+
* HkTooltip chrome, re-anchored per frame while visible) so a caller
|
|
85
|
+
* never mounts ~370 popup instances; it decides only the CONTENT.
|
|
86
|
+
* Without the slot the old behaviour stands: day cells keep their
|
|
87
|
+
* native `title`, gap cells stay silent.
|
|
88
|
+
*
|
|
58
89
|
* Scrolling (2026-11-08): the calendar body rides HkScrollContainer
|
|
59
90
|
* (axis="horizontal", align="center") so the strip uses hikari's own
|
|
60
91
|
* auto-hiding overlay scrollbar and NEVER a native bar — a chart that
|
|
@@ -95,6 +126,14 @@ export default defineComponent({
|
|
|
95
126
|
* contract. Pass "start" to restore the old GitHub-style origin. */
|
|
96
127
|
anchor: { type: String as PropType<"start" | "end">, default: "end" },
|
|
97
128
|
},
|
|
129
|
+
slots: Object as SlotsType<{
|
|
130
|
+
/** Right of the legend: the consumer's range tabs / controls. */
|
|
131
|
+
toolbar?: (props: Record<string, never>) => unknown;
|
|
132
|
+
/** One cell's tooltip content. Fires for day AND gap cells; the
|
|
133
|
+
* component renders it in a single shared hover popup (the
|
|
134
|
+
* HkTooltip chrome), re-anchored to the hovered cell per frame. */
|
|
135
|
+
tooltip?: (props: { cell: CalendarCell }) => unknown;
|
|
136
|
+
}>,
|
|
98
137
|
setup(props, { slots }) {
|
|
99
138
|
const { t } = useI18n();
|
|
100
139
|
|
|
@@ -155,12 +194,174 @@ export default defineComponent({
|
|
|
155
194
|
{ date: dateLabel(d.date), n: d.value, unit: props.unit },
|
|
156
195
|
);
|
|
157
196
|
|
|
197
|
+
// ── the caller-decided cell tooltip ────────────────────────────────
|
|
198
|
+
// One shared popup for the whole grid (HkTooltip's chrome and lazy
|
|
199
|
+
// machinery) — a caller slot decides the CONTENT per cell; the
|
|
200
|
+
// component never mounts per-cell popup instances. Inactive unless
|
|
201
|
+
// the slot is present: without it, day cells keep their native
|
|
202
|
+
// `title` and gap cells stay silent (the pre-tooltip contract).
|
|
203
|
+
const hasTooltipSlot = computed(() => !!slots.tooltip);
|
|
204
|
+
|
|
205
|
+
// date → cell lookup for the delegated hover (cells already carry
|
|
206
|
+
// data-date; no extra attributes on the grid's 370 spans).
|
|
207
|
+
const cellByDate = computed(() => {
|
|
208
|
+
const map = new Map<string, CalendarCell>();
|
|
209
|
+
for (const cell of cells.value) map.set(cell.date, cell);
|
|
210
|
+
return map;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const tip = ref<{ cell: CalendarCell; el: HTMLElement } | null>(null);
|
|
214
|
+
const tipVisible = ref(false);
|
|
215
|
+
const tipPopupId = `hk-calendar-heatmap-tip-${++popupSeq}`;
|
|
216
|
+
const tipManager = usePopupManager();
|
|
217
|
+
let tipHandle: PopupHandle | null = null;
|
|
218
|
+
const tipZIndex = ref<number | null>(null);
|
|
219
|
+
const tipEverShown = ref(false);
|
|
220
|
+
let tipShowTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
let tipPositionFrame: number | null = null;
|
|
222
|
+
let tipArmedCell: string | null = null;
|
|
223
|
+
|
|
224
|
+
function tipEngage() {
|
|
225
|
+
if (tipEverShown.value) return;
|
|
226
|
+
tipEverShown.value = true;
|
|
227
|
+
if (!tipHandle) tipHandle = tipManager.register("tooltip", false);
|
|
228
|
+
tipZIndex.value = tipHandle.zIndex;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tipStopTracking() {
|
|
232
|
+
if (tipPositionFrame !== null) cancelAnimationFrame(tipPositionFrame);
|
|
233
|
+
tipPositionFrame = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function tipUpdatePosition() {
|
|
237
|
+
if (!tip.value || !popupRef.value) return;
|
|
238
|
+
applyTooltipPosition(popupRef.value, tip.value.el.getBoundingClientRect(), "top");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function tipTrackWhileVisible() {
|
|
242
|
+
if (tipPositionFrame !== null) return;
|
|
243
|
+
const tick = () => {
|
|
244
|
+
tipPositionFrame = null;
|
|
245
|
+
if (!tipVisible.value) return;
|
|
246
|
+
// The hovered cell can move without a pointer event (the strip's
|
|
247
|
+
// horizontal scroll) — re-read its rect per frame while visible.
|
|
248
|
+
tipUpdatePosition();
|
|
249
|
+
tipPositionFrame = requestAnimationFrame(tick);
|
|
250
|
+
};
|
|
251
|
+
tipPositionFrame = requestAnimationFrame(tick);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function tipShowNow() {
|
|
255
|
+
if (tipShowTimer !== null) {
|
|
256
|
+
clearTimeout(tipShowTimer);
|
|
257
|
+
tipShowTimer = null;
|
|
258
|
+
}
|
|
259
|
+
tipVisible.value = true;
|
|
260
|
+
tipTrackWhileVisible();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function tipHide() {
|
|
264
|
+
if (tipShowTimer !== null) {
|
|
265
|
+
clearTimeout(tipShowTimer);
|
|
266
|
+
tipShowTimer = null;
|
|
267
|
+
}
|
|
268
|
+
tipArmedCell = null;
|
|
269
|
+
tipVisible.value = false;
|
|
270
|
+
tipStopTracking();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The cell a pointer event landed on (delegated: the spans carry
|
|
274
|
+
* data-date; find the closest one from the event target). */
|
|
275
|
+
function cellFromEvent(e: Event): { cell: CalendarCell; el: HTMLElement } | null {
|
|
276
|
+
const target = e.target as HTMLElement | null;
|
|
277
|
+
const span = target?.closest?.("[data-date]") as HTMLElement | null;
|
|
278
|
+
if (!span) return null;
|
|
279
|
+
const date = span.getAttribute("data-date");
|
|
280
|
+
if (!date) return null;
|
|
281
|
+
const cell = cellByDate.value.get(date);
|
|
282
|
+
return cell ? { cell, el: span } : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function onGridPointerover(e: PointerEvent) {
|
|
286
|
+
if (!hasTooltipSlot.value) return;
|
|
287
|
+
if (performance.now() - lastTouchAt < 600) return;
|
|
288
|
+
const hit = cellFromEvent(e);
|
|
289
|
+
if (!hit) return;
|
|
290
|
+
if (tipArmedCell === hit.cell.date) return;
|
|
291
|
+
tipArmedCell = hit.cell.date;
|
|
292
|
+
tip.value = hit;
|
|
293
|
+
tipEngage();
|
|
294
|
+
// Cell→cell travel with the popup already up swaps content in
|
|
295
|
+
// place (no hide/show flicker); a cold hover waits the delay.
|
|
296
|
+
if (tipVisible.value) tipShowNow();
|
|
297
|
+
else {
|
|
298
|
+
if (tipShowTimer !== null) clearTimeout(tipShowTimer);
|
|
299
|
+
tipShowTimer = setTimeout(tipShowNow, TOOLTIP_DELAY_MS);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function onGridPointerleave(e: PointerEvent) {
|
|
304
|
+
if (!hasTooltipSlot.value) return;
|
|
305
|
+
// Touch's tap-toggle owns dismissal: the boundary-release chain
|
|
306
|
+
// after a touch pointerup fires pointerleave on the tapped cell
|
|
307
|
+
// in the same gesture (implicit capture release), which would
|
|
308
|
+
// close the tooltip the tap just opened (R2). Desktop leave hides.
|
|
309
|
+
if (e.pointerType === "touch") return;
|
|
310
|
+
tipHide();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Touch has no hover: a tap arms the tooltip for that cell at once,
|
|
314
|
+
// and a tap anywhere else dismisses it (the outside-listener lives
|
|
315
|
+
// only while a touch tip is up).
|
|
316
|
+
let lastTouchAt = -Infinity;
|
|
317
|
+
const popupRef = ref<HTMLElement | null>(null);
|
|
318
|
+
const gridRef = ref<HTMLElement | null>(null);
|
|
319
|
+
function onOutsideTap(e: PointerEvent) {
|
|
320
|
+
const target = e.target as HTMLElement | null;
|
|
321
|
+
if (gridRef.value?.contains(target ?? null)) return;
|
|
322
|
+
tipHide();
|
|
323
|
+
document.removeEventListener("pointerdown", onOutsideTap, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function onGridPointerdown(e: PointerEvent) {
|
|
327
|
+
if (!hasTooltipSlot.value || e.pointerType === "mouse") return;
|
|
328
|
+
lastTouchAt = performance.now();
|
|
329
|
+
const hit = cellFromEvent(e);
|
|
330
|
+
if (!hit) return;
|
|
331
|
+
if (tipVisible.value && tipArmedCell === hit.cell.date) {
|
|
332
|
+
tipHide();
|
|
333
|
+
document.removeEventListener("pointerdown", onOutsideTap, true);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
tipArmedCell = hit.cell.date;
|
|
337
|
+
tip.value = hit;
|
|
338
|
+
tipEngage();
|
|
339
|
+
tipShowNow();
|
|
340
|
+
document.removeEventListener("pointerdown", onOutsideTap, true);
|
|
341
|
+
document.addEventListener("pointerdown", onOutsideTap, true);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
onBeforeUnmount(() => {
|
|
345
|
+
tipHide();
|
|
346
|
+
document.removeEventListener("pointerdown", onOutsideTap, true);
|
|
347
|
+
if (tipHandle) {
|
|
348
|
+
tipManager.unregister(tipHandle.id);
|
|
349
|
+
tipHandle = null;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
158
353
|
const cellStyle = (cell: CalendarCell) =>
|
|
159
354
|
cell.kind === "gap"
|
|
160
355
|
? {
|
|
161
356
|
borderRadius: "3px",
|
|
162
|
-
background:
|
|
163
|
-
|
|
357
|
+
background: HEATMAP_GAP_FILL,
|
|
358
|
+
// The dimmed wash is presentational, but without a tooltip
|
|
359
|
+
// slot the gaps stay INERT exactly as before (R2): with the
|
|
360
|
+
// slot they must stay hoverable — they are the popup's
|
|
361
|
+
// engage surface.
|
|
362
|
+
pointerEvents: hasTooltipSlot.value
|
|
363
|
+
? undefined
|
|
364
|
+
: ("none" as const),
|
|
164
365
|
}
|
|
165
366
|
: {
|
|
166
367
|
borderRadius: "3px",
|
|
@@ -298,8 +499,12 @@ export default defineComponent({
|
|
|
298
499
|
))}
|
|
299
500
|
</div>
|
|
300
501
|
|
|
301
|
-
{/* The grid itself: cells flow column-first into 7 rows.
|
|
502
|
+
{/* The grid itself: cells flow column-first into 7 rows.
|
|
503
|
+
* Hover is delegated on the grid (cells carry data-date);
|
|
504
|
+
* with a `tooltip` slot every cell — gaps included — is a
|
|
505
|
+
* live hover target for the shared popup below. */}
|
|
302
506
|
<div
|
|
507
|
+
ref={gridRef}
|
|
303
508
|
data-testid="hk-calendar-heatmap"
|
|
304
509
|
data-max={max.value}
|
|
305
510
|
role="img"
|
|
@@ -317,6 +522,9 @@ export default defineComponent({
|
|
|
317
522
|
gridAutoFlow: "column",
|
|
318
523
|
gap: `${props.cellGap}px`,
|
|
319
524
|
}}
|
|
525
|
+
onPointerover={onGridPointerover}
|
|
526
|
+
onPointerleave={onGridPointerleave}
|
|
527
|
+
onPointerdown={onGridPointerdown}
|
|
320
528
|
>
|
|
321
529
|
{cells.value.map((cell, i) => (
|
|
322
530
|
<span
|
|
@@ -330,7 +538,10 @@ export default defineComponent({
|
|
|
330
538
|
: {
|
|
331
539
|
"data-level": cell.level,
|
|
332
540
|
"data-date": cell.date,
|
|
333
|
-
|
|
541
|
+
// With a tooltip slot the shared popup owns the
|
|
542
|
+
// cell's explanation — a native `title` would
|
|
543
|
+
// double up on hover (and never fires for gaps).
|
|
544
|
+
title: hasTooltipSlot.value ? undefined : cellTitle(cell),
|
|
334
545
|
})}
|
|
335
546
|
style={cellStyle(cell)}
|
|
336
547
|
/>
|
|
@@ -339,6 +550,31 @@ export default defineComponent({
|
|
|
339
550
|
</div>
|
|
340
551
|
</div>
|
|
341
552
|
</HkScrollContainer>
|
|
553
|
+
|
|
554
|
+
{/* The shared cell tooltip: one lazy popup for the whole grid,
|
|
555
|
+
* teleported to the overlay root and pinned to the hovered cell
|
|
556
|
+
* with the shared HkTooltip geometry (zoom-aware, clamped,
|
|
557
|
+
* pixel-snapped). Content is the caller's `tooltip` slot for
|
|
558
|
+
* `tip.cell`; the popup mounts on first engage and stays until
|
|
559
|
+
* unmount so re-shows never churn. */}
|
|
560
|
+
<Teleport to={ensureOverlayRoot()}>
|
|
561
|
+
{tipEverShown.value && tip.value && (
|
|
562
|
+
<div
|
|
563
|
+
ref={popupRef}
|
|
564
|
+
id={tipPopupId}
|
|
565
|
+
role="tooltip"
|
|
566
|
+
class={[
|
|
567
|
+
"hk-tooltip-popup",
|
|
568
|
+
"hk-tooltip-top",
|
|
569
|
+
tipVisible.value ? "hk-tooltip-visible" : "",
|
|
570
|
+
"hk-calendar-heatmap-tip",
|
|
571
|
+
]}
|
|
572
|
+
style={tipZIndex.value != null ? { zIndex: tipZIndex.value } : {}}
|
|
573
|
+
>
|
|
574
|
+
{slots.tooltip?.({ cell: tip.value.cell })}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</Teleport>
|
|
342
578
|
</div>
|
|
343
579
|
);
|
|
344
580
|
},
|
|
@@ -5,16 +5,22 @@
|
|
|
5
5
|
* bare alpha tint — a lone chip over wallpaper reads as a hole).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/* Both flex links must be allowed to shrink below their content or the
|
|
9
|
+
* chip's text truncation never arms: min-width:auto would keep the chip
|
|
10
|
+
* at its nowrap min-content width forever. With no consumer width bound
|
|
11
|
+
* the chip simply renders at its natural width. */
|
|
8
12
|
.hk-filter-chip-anchor {
|
|
9
13
|
position: relative;
|
|
10
14
|
display: inline-flex;
|
|
11
15
|
align-items: center;
|
|
16
|
+
min-width: 0;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
.hk-filter-chip {
|
|
15
20
|
display: inline-flex;
|
|
16
21
|
align-items: center;
|
|
17
22
|
gap: var(--space-6);
|
|
23
|
+
min-width: 0;
|
|
18
24
|
/* Vertical padding = trigger padding + one track step: the chip shares
|
|
19
25
|
* its row with segmented HkTabs-style chrome, whose track adds
|
|
20
26
|
* var(--space-2) around its triggers (the wowsp filter bar's alignment
|
|
@@ -60,6 +66,15 @@
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/* Long filter values ellipsize once a consumer bounds the row (both
|
|
70
|
+
* flex links above unlock shrinking); unbounded, the chip renders at
|
|
71
|
+
* its natural nowrap width and nothing truncates. */
|
|
72
|
+
.hk-filter-chip-text {
|
|
73
|
+
min-width: 0;
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
text-overflow: ellipsis;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
.hk-filter-chip-caret {
|
|
64
79
|
opacity: 0.6;
|
|
65
80
|
flex: 0 0 auto;
|
|
@@ -228,6 +228,12 @@
|
|
|
228
228
|
|
|
229
229
|
/* Keyboard highlight face — identical values to the popup-select item's
|
|
230
230
|
* [data-highlighted]; hover comes from mi.item's own guarded :hover. */
|
|
231
|
+
/* The add row's label rides the shared menu-item label grammar
|
|
232
|
+
* (flex fill, optical offset, italic cushion, ellipsis). */
|
|
233
|
+
.hk-filter-add-item-label {
|
|
234
|
+
@include mi.label;
|
|
235
|
+
}
|
|
236
|
+
|
|
231
237
|
.hk-filter-add-item[data-highlighted] {
|
|
232
238
|
background: var(--c-primary-subtle, rgb(var(--color-primary) / 8%));
|
|
233
239
|
border-color: var(--c-primary-medium, rgb(var(--color-primary) / 25%));
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
align-items: center;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/* Popup body: the panel and the mobile confirm footer stack in one
|
|
16
|
+
* column (the panel owns its own bounded width). */
|
|
17
|
+
.hk-filter-trigger-body {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
.hk-filter-trigger-footer {
|
|
16
23
|
display: flex;
|
|
17
24
|
justify-content: flex-end;
|
|
@@ -114,7 +114,7 @@ describe("calendarGrid", () => {
|
|
|
114
114
|
expect(cells[6]).toMatchObject({ kind: "gap", phase: "after" });
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("
|
|
117
|
+
it("dims the before-first region and never claims a fake zero", () => {
|
|
118
118
|
const cells = calendarGrid(
|
|
119
119
|
[{ date: "2025-12-05", value: 5 }],
|
|
120
120
|
"2025-12-07",
|
|
@@ -138,7 +138,7 @@ describe("calendarGrid", () => {
|
|
|
138
138
|
expect(cells[1]).toMatchObject({ kind: "day", date: "2025-12-02", level: 0 });
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
it("clamps a malformed future `first` to
|
|
141
|
+
it("clamps a malformed future `first` to dim nothing", () => {
|
|
142
142
|
const cells = calendarGrid(
|
|
143
143
|
[{ date: MON, value: 5 }],
|
|
144
144
|
"2025-12-03",
|
|
@@ -151,10 +151,10 @@ describe("calendarGrid", () => {
|
|
|
151
151
|
expect(calendarGrid([], "2025-12-03", null)).toEqual([]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
it("never
|
|
154
|
+
it("never dims a day the server shipped, even past a skewed `today`", () => {
|
|
155
155
|
// A skewed server clock (out of contract): the series runs one day
|
|
156
156
|
// past `today`. The shipped day must render as a real day — every
|
|
157
|
-
// day of the series vanishing behind the "after"
|
|
157
|
+
// day of the series vanishing behind the "after" dim is the
|
|
158
158
|
// failure mode this guards.
|
|
159
159
|
const cells = calendarGrid(
|
|
160
160
|
[
|
|
@@ -169,7 +169,7 @@ describe("calendarGrid", () => {
|
|
|
169
169
|
value: 3,
|
|
170
170
|
level: 3,
|
|
171
171
|
});
|
|
172
|
-
// The unshipped padding past it still
|
|
172
|
+
// The unshipped padding past it still dims.
|
|
173
173
|
expect(cells.find((c) => c.date === "2025-12-03")).toMatchObject({
|
|
174
174
|
kind: "gap",
|
|
175
175
|
phase: "after",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* The server ships a DENSE daily series (oldest → newest, zero-filled,
|
|
8
8
|
* inclusive of today, plus the account's first activity date).
|
|
9
9
|
* Everything the component renders — the full-year week grid with its
|
|
10
|
-
*
|
|
10
|
+
* dimmed no-data gaps, the month markers — derives here so each
|
|
11
11
|
* derivation is unit-testable without a component mount.
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -71,8 +71,9 @@ export function todayIso(): string {
|
|
|
71
71
|
/**
|
|
72
72
|
* One heatmap cell: a real day of the series, or a gap the account has
|
|
73
73
|
* no data for — before its first activity, or after today (the rest of
|
|
74
|
-
* the always-full-year window). Gaps render
|
|
75
|
-
*
|
|
74
|
+
* the always-full-year window). Gaps render as the dimmed no-data wash
|
|
75
|
+
* (the gap channel sits BELOW the empty level); they never claim a
|
|
76
|
+
* fake "0 consumption" for a day that never was.
|
|
76
77
|
*/
|
|
77
78
|
export type CalendarCell =
|
|
78
79
|
| { kind: "day"; date: string; value: number; level: 0 | 1 | 2 | 3 | 4 }
|
|
@@ -83,7 +84,10 @@ export type CalendarCell =
|
|
|
83
84
|
* back to the window's first Monday, tail cells to Sunday — so a
|
|
84
85
|
* trailing-year window ALWAYS lands on a full-year-wide grid, however
|
|
85
86
|
* young the account is. Every cell carries its real calendar date;
|
|
86
|
-
* cells outside [first … today] become gaps (
|
|
87
|
+
* cells outside [first … today] become gaps (dimmed; inert without
|
|
88
|
+
* the tooltip slot — with one they are the popup's engage surface).
|
|
89
|
+
* The Rust/WASM mirror keeps them inert unconditionally (no popup
|
|
90
|
+
* runtime in that form).
|
|
87
91
|
*
|
|
88
92
|
* `maxOverride` pins the intensity scale (R2: the component's `max`
|
|
89
93
|
* prop must reach the buckets, not just the data-max attribute — a
|
|
@@ -103,17 +107,17 @@ export function calendarGrid(
|
|
|
103
107
|
const span = total + ((7 - (total % 7)) % 7);
|
|
104
108
|
const max = maxOverride ?? days.reduce((m, d) => Math.max(m, d.value), 0);
|
|
105
109
|
const valueByDate = new Map(days.map((d) => [d.date, d.value]));
|
|
106
|
-
// A malformed `first` from the future
|
|
110
|
+
// A malformed `first` from the future dims nothing (the clamp
|
|
107
111
|
// keeps the before-check honest).
|
|
108
|
-
const
|
|
112
|
+
const gapFrom = first && first <= today ? first : null;
|
|
109
113
|
const cells: CalendarCell[] = [];
|
|
110
114
|
for (let i = 0; i < span; i++) {
|
|
111
115
|
const date = shiftIso(gridStart, i);
|
|
112
|
-
// Never
|
|
116
|
+
// Never dim a date the server actually shipped: a series whose
|
|
113
117
|
// last row runs past `today` (a skewed server clock, out of
|
|
114
118
|
// contract) would otherwise render EVERY one of its days as a
|
|
115
|
-
// future gap — the data would vanish behind the
|
|
116
|
-
// before-first
|
|
119
|
+
// future gap — the data would vanish behind the dimming. (The
|
|
120
|
+
// before-first dimming below deliberately does NOT consult the map:
|
|
117
121
|
// the server fills the pre-anchor stretch with dense zeros as
|
|
118
122
|
// filler, and the account's first-activity anchor is what decides
|
|
119
123
|
// that no data existed, however many filler rows arrive.)
|
|
@@ -121,7 +125,7 @@ export function calendarGrid(
|
|
|
121
125
|
cells.push({ kind: "gap", date, phase: "after" });
|
|
122
126
|
continue;
|
|
123
127
|
}
|
|
124
|
-
if (
|
|
128
|
+
if (gapFrom && date < gapFrom) {
|
|
125
129
|
cells.push({ kind: "gap", date, phase: "before" });
|
|
126
130
|
continue;
|
|
127
131
|
}
|