From 1c2b229805ff3f6e23e48fd3704f1fe396aa3bed Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Thu, 18 Jun 2026 11:01:13 -0500 Subject: [PATCH] Add current-timeframe border and 'No data' tooltip to checkerboard chart (#47812) **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 image image ## 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 --- ...update-chart-styles-for-current-and-future | 1 + .../cards/ChartCard/CheckerboardViz.tests.tsx | 109 ++++++++++++++++++ .../cards/ChartCard/CheckerboardViz.tsx | 64 +++++++--- .../cards/ChartCard/_styles.scss | 7 ++ 4 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 changes/47811-update-chart-styles-for-current-and-future diff --git a/changes/47811-update-chart-styles-for-current-and-future b/changes/47811-update-chart-styles-for-current-and-future new file mode 100644 index 0000000000..71d71c2384 --- /dev/null +++ b/changes/47811-update-chart-styles-for-current-and-future @@ -0,0 +1 @@ +- Updated checkerboard graph to make it more clear which square represented the current time, and which squares were in the future. \ No newline at end of file diff --git a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx index 0fd11ff2aa..1df2ac2486 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tests.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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"); + }); + }); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx index a56368f976..9c72067b74 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx +++ b/frontend/pages/DashboardPage/cards/ChartCard/CheckerboardViz.tsx @@ -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 ( handleMouseEnter(cell, e)} onMouseLeave={handleMouseLeave} /> @@ -386,13 +416,17 @@ const CheckerboardViz = ({ {hoveredCell.dayLabel}, {hoveredCell.hourLabel}
- {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`)}
)} diff --git a/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss b/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss index b671393720..29977f2868 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss +++ b/frontend/pages/DashboardPage/cards/ChartCard/_styles.scss @@ -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; + } } }