Add current-timeframe border and 'No data' tooltip to checkerboard chart (#47812)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #47811 # Details - Outline the slot containing 'now' (the timeframe still being collected) with a fleet-black-50 border. - Show 'No data' instead of a host count in the tooltip for the current and future timeframes. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually <img width="708" height="416" alt="image" src="https://github.com/user-attachments/assets/2878c72b-6c56-4302-b77f-2c9ebdaf9c8c" /> <img width="706" height="412" alt="image" src="https://github.com/user-attachments/assets/a17a37a3-7264-42aa-9880-db3c68946c56" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced checkerboard graph visualization to clearly distinguish current time slots from future (uncollected) time slots with distinct visual styling * Updated tooltips and accessibility labels to display "No data" for future time periods * **Tests** * Added test coverage for current and future time cell behavior <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Updated checkerboard graph to make it more clear which square represented the current time, and which squares were in the future.
|
||||
@@ -654,4 +654,113 @@ describe("CheckerboardViz", () => {
|
||||
// 11 of 12 rows should be level-0 (only slot 0 has data)
|
||||
expect(level0Count).toBe(11);
|
||||
});
|
||||
|
||||
describe("current timeframe and future cells", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Pin "now" to 9am on Mar 2 — slot 4 (floor(9 / 2)) of the second day
|
||||
// in the generated data. Mar 1 is in the past, Mar 2 slot 4 is current,
|
||||
// and everything after (Mar 2 slots 5+ and all of Mar 3) is the future.
|
||||
jest.setSystemTime(new Date("2026-03-02T09:00:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("outlines exactly the slot that contains 'now' with the current-cell class", async () => {
|
||||
const data = generateData(3);
|
||||
const { container } = renderWithSetup(
|
||||
<CheckerboardViz data={data} selectedDays={14} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll("rect").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const currentCells = container.querySelectorAll(
|
||||
"rect.checkerboard-viz__cell--current"
|
||||
);
|
||||
expect(currentCells).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows 'No data' in the tooltip for the current timeframe even when it has a value", async () => {
|
||||
// The current slot carries a non-zero value in the generated data, but
|
||||
// it's still being collected, so the tooltip reads "No data".
|
||||
const data = generateData(3);
|
||||
const { container } = renderWithSetup(
|
||||
<CheckerboardViz data={data} selectedDays={14} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll("rect").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const currentCell = container.querySelector(
|
||||
"rect.checkerboard-viz__cell--current"
|
||||
);
|
||||
fireEvent.mouseEnter(currentCell as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector(".chart-card__tooltip-value")
|
||||
).toHaveTextContent("No data");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No data' (not the formatter output) for future timeframes", async () => {
|
||||
const formatter = jest.fn(
|
||||
({ value }: { value: number }) => `${value} hosts`
|
||||
);
|
||||
const data = generateData(3);
|
||||
const { container } = renderWithSetup(
|
||||
<CheckerboardViz
|
||||
data={data}
|
||||
selectedDays={14}
|
||||
tooltipFormatter={formatter}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll("rect").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The last rendered cell is the final slot of the latest day (Mar 3),
|
||||
// which is entirely in the future.
|
||||
const rects = container.querySelectorAll("rect");
|
||||
const futureCell = rects[rects.length - 1];
|
||||
expect(futureCell.getAttribute("aria-label")).toContain("No data");
|
||||
|
||||
fireEvent.mouseEnter(futureCell);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector(".chart-card__tooltip-value")
|
||||
).toHaveTextContent("No data");
|
||||
});
|
||||
});
|
||||
|
||||
it("still reports the value for past timeframes", async () => {
|
||||
const data = generateData(3); // every slot has percentage 50
|
||||
const { container } = renderWithSetup(
|
||||
<CheckerboardViz data={data} selectedDays={14} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll("rect").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The first rendered cell is Mar 1 slot 0 — comfortably in the past.
|
||||
const pastCell = container.querySelector("rect") as SVGRectElement;
|
||||
expect(pastCell.getAttribute("aria-label")).not.toContain("No data");
|
||||
|
||||
fireEvent.mouseEnter(pastCell);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector(".chart-card__tooltip-value")
|
||||
).toHaveTextContent("50% of hosts");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ interface ICellData {
|
||||
percentage: number;
|
||||
dayLabel: string;
|
||||
hourLabel: string;
|
||||
// The timeframe that contains "now" — the next slot we're still collecting
|
||||
// data for. Gets a highlighted border.
|
||||
isCurrent: boolean;
|
||||
// The current slot and anything after it have no collected data yet, so
|
||||
// their tooltip reads "No data" rather than "0 hosts".
|
||||
isFuture: boolean;
|
||||
}
|
||||
|
||||
interface ICheckerboardVizProps {
|
||||
@@ -121,12 +127,22 @@ const CheckerboardViz = ({
|
||||
const hourRows = 24 / hoursPerSlot;
|
||||
|
||||
const { grid, dayLabels } = useMemo(() => {
|
||||
// Anchor "now" once per build so every cell agrees on which slot is
|
||||
// current. The current slot is the one we're still collecting data for;
|
||||
// it and any later slot have no data yet.
|
||||
const now = new Date();
|
||||
const todayKey = format(now, "yyyy-MM-dd");
|
||||
const currentSlot = Math.floor(now.getHours() / hoursPerSlot);
|
||||
|
||||
// 24h view: each incoming data point becomes a single column in a
|
||||
// one-row strip. No day grouping, no slot aggregation — the backend has
|
||||
// already produced one point per hour and we render them in order.
|
||||
if (is24h) {
|
||||
const cells: ICellData[] = data.map((point, i) => {
|
||||
const date = parseISO(point.timestamp);
|
||||
const dayKey = format(date, "yyyy-MM-dd");
|
||||
const slot = Math.floor(date.getHours() / hoursPerSlot);
|
||||
const isCurrent = dayKey === todayKey && slot === currentSlot;
|
||||
return {
|
||||
dayIndex: 0,
|
||||
hourRow: i,
|
||||
@@ -135,6 +151,9 @@ const CheckerboardViz = ({
|
||||
percentage: point.percentage,
|
||||
dayLabel: format(date, "EEEE, MMM d"),
|
||||
hourLabel: formatHourLabel(date.getHours()),
|
||||
isCurrent,
|
||||
isFuture:
|
||||
dayKey > todayKey || (dayKey === todayKey && slot >= currentSlot),
|
||||
};
|
||||
});
|
||||
return { grid: cells, dayLabels: ["today"] };
|
||||
@@ -194,6 +213,7 @@ const CheckerboardViz = ({
|
||||
for (let row = 0; row < hourRows; row += 1) {
|
||||
const point = hourMap?.get(row);
|
||||
const hourVal = row * hoursPerSlot;
|
||||
const isCurrent = dayKey === todayKey && row === currentSlot;
|
||||
cells.push({
|
||||
dayIndex,
|
||||
hourRow: row,
|
||||
@@ -202,6 +222,9 @@ const CheckerboardViz = ({
|
||||
total: point?.total,
|
||||
dayLabel: format(date, "EEEE, MMM d"),
|
||||
hourLabel: formatHourLabel(hourVal),
|
||||
isCurrent,
|
||||
isFuture:
|
||||
dayKey > todayKey || (dayKey === todayKey && row >= currentSlot),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -329,11 +352,12 @@ const CheckerboardViz = ({
|
||||
const row = is24h ? 0 : cell.hourRow;
|
||||
const level = getColorLevel(cell);
|
||||
// Filled cells have a bg-colored 1px stroke that visually blends
|
||||
// away. The level-0 (empty) cell uses a colored stroke instead,
|
||||
// so without insetting it would look 1px larger than filled
|
||||
// cells. Inset by half the stroke so the outline's outer edge
|
||||
// sits where the filled cell's invisible stroke does.
|
||||
const inset = level === 0 ? 0.5 : 0;
|
||||
// away. Outlined cells (level-0 and the current-timeframe cell)
|
||||
// use a visible colored stroke instead, so without insetting they
|
||||
// would look 1px larger than filled cells. Inset by half the
|
||||
// stroke so the outline's outer edge sits where the filled cell's
|
||||
// invisible stroke does.
|
||||
const inset = level === 0 || cell.isCurrent ? 0.5 : 0;
|
||||
return (
|
||||
<rect
|
||||
key={`${cell.dayIndex}-${cell.hourRow}`}
|
||||
@@ -343,11 +367,17 @@ const CheckerboardViz = ({
|
||||
height={cellH - inset * 2}
|
||||
rx={3}
|
||||
ry={3}
|
||||
className={`${baseClass}__cell ${baseClass}__cell--level-${level}`}
|
||||
className={classnames(
|
||||
`${baseClass}__cell`,
|
||||
`${baseClass}__cell--level-${level}`,
|
||||
{ [`${baseClass}__cell--current`]: cell.isCurrent }
|
||||
)}
|
||||
role="img"
|
||||
aria-label={`${cell.dayLabel}, ${cell.hourLabel}: ${
|
||||
cell.value
|
||||
} host${cell.value === 1 ? "" : "s"}`}
|
||||
cell.isFuture
|
||||
? "No data"
|
||||
: `${cell.value} host${cell.value === 1 ? "" : "s"}`
|
||||
}`}
|
||||
onMouseEnter={(e) => handleMouseEnter(cell, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
@@ -386,13 +416,17 @@ const CheckerboardViz = ({
|
||||
{hoveredCell.dayLabel}, {hoveredCell.hourLabel}
|
||||
</div>
|
||||
<div className="chart-card__tooltip-value">
|
||||
{tooltipFormatter
|
||||
? tooltipFormatter({
|
||||
value: hoveredCell.value,
|
||||
percentage: hoveredCell.percentage,
|
||||
total: hoveredCell.total,
|
||||
})
|
||||
: `${hoveredCell.percentage}% of hosts`}
|
||||
{/* The current slot and anything after it haven't been collected
|
||||
yet, so there's no value to report — show "No data". */}
|
||||
{hoveredCell.isFuture && "No data"}
|
||||
{!hoveredCell.isFuture &&
|
||||
(tooltipFormatter
|
||||
? tooltipFormatter({
|
||||
value: hoveredCell.value,
|
||||
percentage: hoveredCell.percentage,
|
||||
total: hoveredCell.total,
|
||||
})
|
||||
: `${hoveredCell.percentage}% of hosts`)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -366,6 +366,13 @@
|
||||
fill: var(--level-5);
|
||||
background-color: var(--level-5);
|
||||
}
|
||||
|
||||
// The current timeframe (the slot we're still collecting data for) gets a
|
||||
// visible outline. Declared after the level rules so it overrides the
|
||||
// level-0 stroke at equal specificity.
|
||||
&--current {
|
||||
stroke: $ui-fleet-black-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user