Skip to content

Commit b2b4c51

Browse files
authored
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
1 parent f1bd11a commit b2b4c51

24 files changed

Lines changed: 1414 additions & 517 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Custom (Query mode) and dashboard charts keep non-date x-axis labels like run IDs and task names readable with no configuration: width-aware label thinning, middle-truncation with the full value on hover, and auto-rotation only when labels are long.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Dashboard and custom query line charts now choose how many x-axis time labels to show based on the chart's rendered width, so wide charts show more labels and narrow widgets show fewer.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Improve the activity charts on the task landing pages (agent, standard, scheduled): bar density now adapts to the selected time range so short ranges no longer collapse to a single bar, x-axis labels are width-aware and non-overlapping, the agent charts share a synced hover line, each chart gets a maximize button, and dragging across a chart zooms the Time/Date filter.

apps/webapp/app/components/code/QueryResultsChart.tsx

Lines changed: 129 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhous
22
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
33
import { BarChart3, LineChart } from "lucide-react";
44
import { memo, useMemo } from "react";
5+
import { useMeasure } from "react-use";
56
import { createValueFormatter } from "~/utils/columnFormat";
67
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
78
import type { ChartConfig } from "~/components/primitives/charts/Chart";
89
import { Chart } from "~/components/primitives/charts/ChartCompound";
10+
import { selectEvenlySpacedIndices } from "~/components/primitives/charts/useXAxisTicks";
911
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
1012
import { Callout } from "../primitives/Callout";
1113
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
@@ -18,6 +20,39 @@ const MAX_SVG_ELEMENT_BUDGET = 6_000;
1820
const MIN_DATA_POINTS = 100;
1921
const MAX_DATA_POINTS = 500;
2022

23+
// Width-aware x-axis label density for date-based line charts: reserve room for
24+
// the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX (smaller = denser).
25+
const TIME_AXIS_Y_ALLOWANCE_PX = 56;
26+
const TIME_AXIS_LABEL_SPACING_PX = 40;
27+
const MIN_TIME_AXIS_TICKS = 3;
28+
29+
// Categorical (non-date) x-axis: thin labels to fit, middle-truncate long values
30+
// (run IDs, task names), and auto-rotate when labels are long.
31+
const X_LABEL_PX_PER_CHAR = 6.5;
32+
const X_LABEL_GAP_PX = 16;
33+
const MIN_CATEGORICAL_LABEL_PX = 36;
34+
// Labels longer than this rotate to -45°.
35+
const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10;
36+
// Middle-ellipsis cap for rotated labels (bounds axis height).
37+
const CATEGORICAL_ROTATED_MAX_CHARS = 14;
38+
// Rotated labels pack tighter than horizontal ones.
39+
const CATEGORICAL_ROTATED_LABEL_PX = 32;
40+
const CATEGORICAL_ROTATED_HEIGHT_PX = 80;
41+
const MIN_CATEGORICAL_TICKS = 2;
42+
43+
/**
44+
* Shorten to `maxChars` with a middle ellipsis (e.g. "run_abc…f9c2"), preserving
45+
* the distinguishing tail for IDs that share a prefix.
46+
*/
47+
export function truncateMiddle(value: string, maxChars: number): string {
48+
if (value.length <= maxChars) return value;
49+
if (maxChars <= 1) return value.slice(0, Math.max(0, maxChars));
50+
const keep = maxChars - 1; // room for the ellipsis
51+
const head = Math.ceil(keep / 2);
52+
const tail = Math.floor(keep / 2);
53+
return `${value.slice(0, head)}${value.slice(value.length - tail)}`;
54+
}
55+
2156
interface QueryResultsChartProps {
2257
rows: Record<string, unknown>[];
2358
columns: OutputColumnMetadata[];
@@ -307,7 +342,7 @@ const NICE_TIME_INTERVALS = [
307342
* Generate evenly-spaced tick values for a time axis using "nice" intervals
308343
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
309344
*/
310-
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
345+
export function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
311346
const range = maxTime - minTime;
312347

313348
if (range <= 0) {
@@ -793,6 +828,22 @@ export const QueryResultsChart = memo(function QueryResultsChart({
793828
timeTicks,
794829
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
795830

831+
// Measure the chart width so x-axis label density adapts to it (continuous time
832+
// scale is kept, so data gaps still show).
833+
const [chartMeasureRef, { width: chartWidth }] = useMeasure<HTMLDivElement>();
834+
835+
// Width-aware time-axis ticks: choose how many clean intervals to render based
836+
// on width. Falls back to the default ticks until width is known (first paint).
837+
const widthAwareTimeTicks = useMemo(() => {
838+
if (!isDateBased || !timeDomain || !chartWidth) return timeTicks;
839+
const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX);
840+
const maxTicks = Math.max(
841+
MIN_TIME_AXIS_TICKS,
842+
Math.floor(plotWidth / TIME_AXIS_LABEL_SPACING_PX)
843+
);
844+
return generateTimeTicks(timeDomain[0], timeDomain[1], maxTicks);
845+
}, [isDateBased, timeDomain, timeTicks, chartWidth]);
846+
796847
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
797848
const data = useMemo(() => {
798849
if (isDateBased) {
@@ -1022,6 +1073,41 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10221073
};
10231074
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
10241075

1076+
// Categorical x-axis: thin to fit width, middle-truncate long values, rotate when long.
1077+
const categoricalXAxisProps = useMemo(() => {
1078+
if (isDateBased) return null;
1079+
1080+
const labels = data.map((d) => String(d[xDataKey] ?? ""));
1081+
const maxLabelChars = labels.reduce((max, l) => Math.max(max, l.length), 0);
1082+
const angled = maxLabelChars > CATEGORICAL_HORIZONTAL_MAX_CHARS;
1083+
1084+
const tickFormatter = angled
1085+
? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS)
1086+
: (value: unknown) => String(value ?? "");
1087+
1088+
// Rotated labels pack tighter; horizontal ones need roughly their own width.
1089+
const perLabelPx = angled
1090+
? CATEGORICAL_ROTATED_LABEL_PX
1091+
: Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX);
1092+
const plotWidth = chartWidth > 0 ? Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX) : 0;
1093+
const fitCount =
1094+
plotWidth > 0
1095+
? Math.max(MIN_CATEGORICAL_TICKS, Math.floor(plotWidth / perLabelPx))
1096+
: data.length;
1097+
const ticks =
1098+
fitCount < data.length
1099+
? selectEvenlySpacedIndices(data.length, fitCount).map((i) => data[i][xDataKey] as string)
1100+
: undefined;
1101+
1102+
return {
1103+
tickFormatter,
1104+
angle: angled ? -45 : 0,
1105+
textAnchor: angled ? ("end" as const) : ("middle" as const),
1106+
height: angled ? CATEGORICAL_ROTATED_HEIGHT_PX : undefined,
1107+
...(ticks ? { ticks, interval: 0 as const } : {}),
1108+
};
1109+
}, [isDateBased, data, xDataKey, chartWidth]);
1110+
10251111
// Validation — all hooks must be above this point
10261112
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
10271113

@@ -1070,15 +1156,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10701156
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
10711157
scale: "time" as const,
10721158
// Explicitly specify tick positions so labels appear across the entire range
1073-
ticks: timeTicks ?? undefined,
1159+
ticks: widthAwareTimeTicks ?? undefined,
10741160
...baseXAxisProps,
10751161
}
1076-
: baseXAxisProps;
1162+
: (categoricalXAxisProps ?? baseXAxisProps);
10771163

10781164
// Bar charts always use categorical axis positioning
10791165
// This ensures bars are evenly distributed regardless of data point count
10801166
// (prevents massive bars when there are only a few data points)
1081-
const xAxisPropsForBar = baseXAxisProps;
1167+
const xAxisPropsForBar = isDateBased ? baseXAxisProps : (categoricalXAxisProps ?? baseXAxisProps);
10821168

10831169
const yAxisProps = {
10841170
tickFormatter: yAxisFormatter,
@@ -1089,6 +1175,40 @@ export const QueryResultsChart = memo(function QueryResultsChart({
10891175

10901176
if (chartType === "bar") {
10911177
return (
1178+
<div ref={chartMeasureRef} className="h-full w-full">
1179+
<Chart.Root
1180+
config={chartConfig}
1181+
data={data}
1182+
dataKey={xDataKey}
1183+
series={sortedSeries}
1184+
visibleSeries={visibleSeries}
1185+
labelFormatter={legendLabelFormatter}
1186+
showLegend={showLegend}
1187+
maxLegendItems={fullLegend ? Infinity : 5}
1188+
legendAggregation={config.aggregation}
1189+
legendValueFormatter={tooltipValueFormatter}
1190+
minHeight="300px"
1191+
fillContainer
1192+
onViewAllLegendItems={onViewAllLegendItems}
1193+
legendScrollable={legendScrollable}
1194+
state={isLoading ? "loading" : "loaded"}
1195+
beforeLegend={seriesLimitCallout}
1196+
>
1197+
<Chart.Bar
1198+
xAxisProps={xAxisPropsForBar}
1199+
yAxisProps={yAxisProps}
1200+
stackId={stacked ? "stack" : undefined}
1201+
tooltipLabelFormatter={tooltipLabelFormatter}
1202+
tooltipValueFormatter={tooltipValueFormatter}
1203+
/>
1204+
</Chart.Root>
1205+
</div>
1206+
);
1207+
}
1208+
1209+
// Line or stacked area chart
1210+
return (
1211+
<div ref={chartMeasureRef} className="h-full w-full">
10921212
<Chart.Root
10931213
config={chartConfig}
10941214
data={data}
@@ -1107,46 +1227,16 @@ export const QueryResultsChart = memo(function QueryResultsChart({
11071227
state={isLoading ? "loading" : "loaded"}
11081228
beforeLegend={seriesLimitCallout}
11091229
>
1110-
<Chart.Bar
1111-
xAxisProps={xAxisPropsForBar}
1230+
<Chart.Line
1231+
xAxisProps={xAxisPropsForLine}
11121232
yAxisProps={yAxisProps}
1113-
stackId={stacked ? "stack" : undefined}
1233+
stacked={stacked && visibleSeries.length > 1}
11141234
tooltipLabelFormatter={tooltipLabelFormatter}
11151235
tooltipValueFormatter={tooltipValueFormatter}
1236+
lineType="linear"
11161237
/>
11171238
</Chart.Root>
1118-
);
1119-
}
1120-
1121-
// Line or stacked area chart
1122-
return (
1123-
<Chart.Root
1124-
config={chartConfig}
1125-
data={data}
1126-
dataKey={xDataKey}
1127-
series={sortedSeries}
1128-
visibleSeries={visibleSeries}
1129-
labelFormatter={legendLabelFormatter}
1130-
showLegend={showLegend}
1131-
maxLegendItems={fullLegend ? Infinity : 5}
1132-
legendAggregation={config.aggregation}
1133-
legendValueFormatter={tooltipValueFormatter}
1134-
minHeight="300px"
1135-
fillContainer
1136-
onViewAllLegendItems={onViewAllLegendItems}
1137-
legendScrollable={legendScrollable}
1138-
state={isLoading ? "loading" : "loaded"}
1139-
beforeLegend={seriesLimitCallout}
1140-
>
1141-
<Chart.Line
1142-
xAxisProps={xAxisPropsForLine}
1143-
yAxisProps={yAxisProps}
1144-
stacked={stacked && visibleSeries.length > 1}
1145-
tooltipLabelFormatter={tooltipLabelFormatter}
1146-
tooltipValueFormatter={tooltipValueFormatter}
1147-
lineType="linear"
1148-
/>
1149-
</Chart.Root>
1239+
</div>
11501240
);
11511241
});
11521242

0 commit comments

Comments
 (0)