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:
Scott Gress
2026-06-18 11:01:13 -05:00
committed by GitHub
parent fa425d160f
commit 1c2b229805
4 changed files with 166 additions and 15 deletions
@@ -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;
}
}
}