Files
triggerdotdev--trigger.dev/apps/webapp/test/chartXAxisTicks.test.ts
James Ritchie b2b4c510e2 feat(webapp): improve task and dashboard activity charts (#4064)
## Summary

Improves and unifies the run-activity charts by extracting a shared set
of chart primitives and adopting them on the three task landing pages
(agent, standard, scheduled), with the density and label fixes also
carried over to the dashboard and custom query charts.

Main changes a reviewer should know about:

- **Shared primitives (DRY).** New `ChartCard` (title +
maximize/fullscreen), `ChartSyncContext` (cross-chart hover + zoom
state), `useXAxisTicks` (width-aware tick selection),
`activityTimeAxis`, and `statusColors`, plus a server-side
`activitySeries.server.ts` holding `chooseBucketSeconds`, status
grouping, and the zero-fill helpers. The three task routes and both
presenters were refactored onto these, removing roughly 3x duplicated
tick logic, status-color tables, and bucket-ladder code.
- **Denser bars on short ranges.** Server-side bucketing now uses
`chooseBucketSeconds` (nice-interval ladder, ~72 target, capped at 120
buckets) instead of the hardcoded 1h/6h/1d ladder, so a 5m or 1h range
no longer collapses into a single bar.
- **Width-aware x-axis labels.** Labels are selected to fit the measured
plot width (always first + last, evenly spaced, de-duplicated by
rendered text), stay horizontal, and reflow on panel/window resize.
Y-axis values default to compact form (8K, 1.2M) in
`ChartBar`/`ChartLine`.
- **Synced hover line.** Hovering one of the agent page's three charts
draws a dashed vertical line at the same bucket on the *other* two, and
suppresses it on the hovered chart. It is opt-in via
`ChartSyncProvider`, so single-chart pages are unaffected.
- **Maximize button.** Each chart gets a fullscreen dialog toggle
(reuses the existing dashboard-widget pattern, `v` shortcut while
hovered).
- **Drag-to-zoom on task pages.** Dragging across a task chart sets the
Time/Date filter (`from`/`to` URL params, clearing `period`/`cursor`),
with a From/To tooltip shown during the drag.
- **Custom query charts.** Long categorical x labels (run IDs, task
names) middle-truncate and auto-rotate only when needed, and label
thinning is now width-aware for both bar and line variants. Dashboard
line-chart label density is also width-aware, tuned by a
`TIME_AXIS_LABEL_SPACING_PX` constant.
- **Tests.** 46 new unit tests for the pure logic (bucket selection,
tick spacing, time-axis formatting, zoom range, truncation).

## Intentionally unchanged

- **No click/drag zoom on the dashboard or custom query charts.**
Drag-to-zoom is wired up on the task landing pages only; zooming the
dashboard and custom charts is deliberately deferred to a separate
follow-up PR. A plain click (without a drag) on a task chart is a no-op.
- **The 25 mini activity charts on the Task list (`_index`) page are
untouched.** They are hand-rolled raw-Recharts sparklines kept
deliberately lightweight and do not use these primitives.
- **Other raw-Recharts sparklines are untouched** (the usage sparkline,
errors and prompts pages).
- **No ClickHouse query semantics changed** beyond the bucket-interval
parameter (same filters, same FINAL / `_is_deleted` handling).
- **Webapp-only.** No public package (`packages/*`) changes, so there is
no changeset; the `.server-changes/` entries cover it.

---

## Testing

Added 46 unit tests covering server-side bucket selection, width-aware
tick spacing, time-axis formatting, zoom-range math, and categorical
label truncation (`pnpm --filter webapp run test`), and `pnpm run
typecheck --filter webapp` passes. Manually exercised each task landing
page (agent, standard, scheduled) plus the dashboard and custom query
charts, stepping the Date/Time filter through 5m, 1h, 24h, 7d, and 30d
to confirm dense bars on short ranges, non-overlapping labels that
reflow on resize, the synced hover line across the agent charts, the
maximize button, and drag-to-zoom updating the filter.

---

## Changelog

The activity charts on the task landing pages and the dashboard and
custom query charts now share one set of reusable primitives. X-axis
labels are width-aware so they never overlap and reflow when a panel
resizes, y-axis values are abbreviated (8K, 1.2M), and short time ranges
render dense bars instead of collapsing into a single bar. Hovering any
agent chart mirrors a vertical line on the others, every chart gains a
maximize button, and dragging across a task chart zooms the Time/Date
filter. Long categorical labels such as run IDs and task names
middle-truncate and auto-rotate only when needed.

---



https://github.com/user-attachments/assets/6be09e38-3a0e-4947-b6e3-4839daa2fbe0
2026-06-28 17:26:43 +01:00

125 lines
4.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
dedupeTicksByLabel,
estimateMaxLabels,
selectEvenlySpacedIndices,
selectEvenlySpacedTicks,
} from "~/components/primitives/charts/useXAxisTicks";
describe("selectEvenlySpacedTicks", () => {
const values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
it("always includes first and last", () => {
const ticks = selectEvenlySpacedTicks(values, 4);
expect(ticks[0]).toBe(0);
expect(ticks[ticks.length - 1]).toBe(9);
});
it("spaces ticks evenly", () => {
expect(selectEvenlySpacedTicks(values, 4)).toEqual([0, 3, 6, 9]);
});
it("returns all values when more labels than points are allowed", () => {
expect(selectEvenlySpacedTicks(values, 50)).toEqual(values);
});
it("returns just the endpoints for 2 labels", () => {
expect(selectEvenlySpacedTicks(values, 2)).toEqual([0, 9]);
});
it("returns a single value for 1 label", () => {
expect(selectEvenlySpacedTicks(values, 1)).toEqual([0]);
});
it("handles empty input", () => {
expect(selectEvenlySpacedTicks([], 5)).toEqual([]);
});
it("never produces duplicates", () => {
const ticks = selectEvenlySpacedTicks([0, 1, 2], 3);
expect(new Set(ticks).size).toBe(ticks.length);
});
});
describe("selectEvenlySpacedIndices", () => {
it("includes first and last, evenly spaced", () => {
expect(selectEvenlySpacedIndices(10, 4)).toEqual([0, 3, 6, 9]);
});
it("returns all indices when count >= n", () => {
expect(selectEvenlySpacedIndices(3, 10)).toEqual([0, 1, 2]);
});
it("returns endpoints for count 2", () => {
expect(selectEvenlySpacedIndices(50, 2)).toEqual([0, 49]);
});
it("returns [0] for count <= 1", () => {
expect(selectEvenlySpacedIndices(50, 1)).toEqual([0]);
});
it("handles empty range", () => {
expect(selectEvenlySpacedIndices(0, 5)).toEqual([]);
});
it("always ends at the last index (partial-period start stays even)", () => {
// 74 buckets, room for ~8 labels -> evenly spaced, last index present
const idx = selectEvenlySpacedIndices(74, 8);
expect(idx[0]).toBe(0);
expect(idx[idx.length - 1]).toBe(73);
// gaps are roughly uniform (no tiny first gap)
const gaps = idx.slice(1).map((v, i) => v - idx[i]);
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(9);
});
});
describe("estimateMaxLabels", () => {
it("returns 0 when width is unknown", () => {
expect(estimateMaxLabels(0, 5)).toBe(0);
});
it("fits more labels in a wider chart", () => {
const narrow = estimateMaxLabels(200, 5);
const wide = estimateMaxLabels(800, 5);
expect(wide).toBeGreaterThan(narrow);
});
it("fits fewer labels when labels are wider", () => {
const shortLabels = estimateMaxLabels(400, 5);
const longLabels = estimateMaxLabels(400, 12);
expect(longLabels).toBeLessThanOrEqual(shortLabels);
});
it("always allows at least one label for a positive width", () => {
expect(estimateMaxLabels(10, 100)).toBeGreaterThanOrEqual(1);
});
});
describe("dedupeTicksByLabel", () => {
it("drops adjacent duplicate labels", () => {
const labels = ["A", "A", "B", "B", "C"];
const values = [0, 1, 2, 3, 4];
expect(dedupeTicksByLabel([0, 1, 2, 3, 4], labels, values)).toEqual([0, 2, 4]);
});
it("keeps the last index when its label repeats the previous tick (first+last contract)", () => {
// indices 6 and 9 render the same label; the naive loop would drop index 9
// and leave the right edge unlabeled. The last index must win.
const labels = ["a", "b", "c", "d", "e", "f", "X", "g", "h", "X"];
const values = labels.map((_, i) => i);
const ticks = dedupeTicksByLabel([0, 3, 6, 9], labels, values);
expect(ticks[ticks.length - 1]).toBe(9);
expect(ticks).toEqual([0, 3, 9]);
});
it("leaves already-unique labels untouched", () => {
const labels = ["Jan", "Feb", "Mar"];
const values = ["Jan", "Feb", "Mar"];
expect(dedupeTicksByLabel([0, 1, 2], labels, values)).toEqual(["Jan", "Feb", "Mar"]);
});
it("handles an empty selection", () => {
expect(dedupeTicksByLabel([], [], [])).toEqual([]);
});
});