Files
triggerdotdev--trigger.dev/apps/webapp/test/activitySeries.server.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

133 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
chooseBucketSeconds,
groupRunStatus,
RUN_STATUS_GROUPS,
zeroFillGroupedSeries,
zeroFillScalarSeries,
} from "~/presenters/v3/activitySeries.server";
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("chooseBucketSeconds", () => {
it("uses fine buckets for sub-hour ranges (the 5-minute bug)", () => {
// 5 minutes should NOT collapse to a single 1h bar.
expect(chooseBucketSeconds(5 * MINUTE)).toBe(5); // 60 buckets
expect(chooseBucketSeconds(1 * MINUTE)).toBe(1); // 60 buckets
expect(chooseBucketSeconds(30 * MINUTE)).toBe(30); // 60 buckets
});
it("scales the interval up for longer ranges", () => {
expect(chooseBucketSeconds(1 * HOUR)).toBe(60);
expect(chooseBucketSeconds(6 * HOUR)).toBe(300);
expect(chooseBucketSeconds(7 * DAY)).toBe(7200);
expect(chooseBucketSeconds(30 * DAY)).toBe(43200);
});
it("never exceeds the bucket ceiling", () => {
const ranges = [1 * MINUTE, 5 * MINUTE, 1 * HOUR, 24 * HOUR, 7 * DAY, 30 * DAY];
for (const range of ranges) {
const secs = chooseBucketSeconds(range);
const count = range / 1000 / secs;
expect(count).toBeLessThanOrEqual(120);
expect(count).toBeGreaterThan(0);
}
});
it("falls back to a computed interval for ranges beyond the ladder", () => {
const huge = 2000 * DAY;
const secs = chooseBucketSeconds(huge);
const count = huge / 1000 / secs;
expect(count).toBeLessThanOrEqual(120);
});
it("honours a custom target", () => {
// Smaller target => wider buckets => fewer bars.
const wide = chooseBucketSeconds(1 * HOUR, { targetBuckets: 12 });
const dense = chooseBucketSeconds(1 * HOUR, { targetBuckets: 72 });
expect(wide).toBeGreaterThan(dense);
});
});
describe("groupRunStatus", () => {
it("maps raw statuses to chart groups", () => {
expect(groupRunStatus("COMPLETED_SUCCESSFULLY")).toBe("COMPLETED");
expect(groupRunStatus("CRASHED")).toBe("FAILED");
expect(groupRunStatus("EXPIRED")).toBe("CANCELED");
expect(groupRunStatus("EXECUTING")).toBe("RUNNING");
expect(groupRunStatus("SOMETHING_UNKNOWN")).toBeUndefined();
});
});
describe("zeroFillGroupedSeries", () => {
it("emits a contiguous, fully zero-filled series", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:05.000Z"); // 5 seconds
const bucketSeconds = 1;
const at2s = Math.floor(new Date("2026-06-22T00:00:02.000Z").getTime() / 1000);
const points = zeroFillGroupedSeries({
rows: [{ bucket: at2s, status: "COMPLETED_SUCCESSFULLY", val: 3 }],
from,
to,
bucketSeconds,
orderedKeys: RUN_STATUS_GROUPS,
groupFn: groupRunStatus,
fallbackKey: "RUNNING",
});
expect(points).toHaveLength(5);
// Every point has every key (stable legend).
for (const p of points) {
for (const key of RUN_STATUS_GROUPS) {
expect(typeof p[key]).toBe("number");
}
}
// The matching bucket carries the value; the rest are zero.
const filled = points.find((p) => p.bucket === at2s * 1000);
expect(filled?.COMPLETED).toBe(3);
expect(points.filter((p) => p.COMPLETED > 0)).toHaveLength(1);
});
it("uses identity grouping when no groupFn is provided", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:02.000Z");
const at0 = Math.floor(from.getTime() / 1000);
const points = zeroFillGroupedSeries({
rows: [{ bucket: at0, status: "ACTIVE", val: 7 }],
from,
to,
bucketSeconds: 1,
orderedKeys: ["ACTIVE", "CLOSED", "EXPIRED"] as const,
});
expect(points).toHaveLength(2);
expect(points[0].ACTIVE).toBe(7);
expect(points[0].CLOSED).toBe(0);
});
});
describe("zeroFillScalarSeries", () => {
it("zero-fills a single series", () => {
const from = new Date("2026-06-22T00:00:00.000Z");
const to = new Date("2026-06-22T00:00:03.000Z");
const at1 = Math.floor(new Date("2026-06-22T00:00:01.000Z").getTime() / 1000);
const points = zeroFillScalarSeries({
rows: [{ bucket: at1, val: 42 }],
from,
to,
bucketSeconds: 1,
seriesKey: "cost",
});
expect(points).toHaveLength(3);
expect(points.find((p) => p.bucket === at1 * 1000)?.cost).toBe(42);
expect(points.filter((p) => p.cost > 0)).toHaveLength(1);
});
});