diff --git a/.server-changes/custom-chart-categorical-x-axis.md b/.server-changes/custom-chart-categorical-x-axis.md new file mode 100644 index 00000000000..76f89c4a28c --- /dev/null +++ b/.server-changes/custom-chart-categorical-x-axis.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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. diff --git a/.server-changes/dashboard-line-chart-x-axis-density.md b/.server-changes/dashboard-line-chart-x-axis-density.md new file mode 100644 index 00000000000..2b249717540 --- /dev/null +++ b/.server-changes/dashboard-line-chart-x-axis-density.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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. diff --git a/.server-changes/task-landing-activity-charts.md b/.server-changes/task-landing-activity-charts.md new file mode 100644 index 00000000000..8cb7e5f264d --- /dev/null +++ b/.server-changes/task-landing-activity-charts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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. diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 97013c192a4..fd7b3c32f4c 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -2,10 +2,12 @@ import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhous import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { BarChart3, LineChart } from "lucide-react"; import { memo, useMemo } from "react"; +import { useMeasure } from "react-use"; import { createValueFormatter } from "~/utils/columnFormat"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; +import { selectEvenlySpacedIndices } from "~/components/primitives/charts/useXAxisTicks"; import { ChartBlankState } from "../primitives/charts/ChartBlankState"; import { Callout } from "../primitives/Callout"; import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; @@ -18,6 +20,39 @@ const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; +// Width-aware x-axis label density for date-based line charts: reserve room for +// the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX (smaller = denser). +const TIME_AXIS_Y_ALLOWANCE_PX = 56; +const TIME_AXIS_LABEL_SPACING_PX = 40; +const MIN_TIME_AXIS_TICKS = 3; + +// Categorical (non-date) x-axis: thin labels to fit, middle-truncate long values +// (run IDs, task names), and auto-rotate when labels are long. +const X_LABEL_PX_PER_CHAR = 6.5; +const X_LABEL_GAP_PX = 16; +const MIN_CATEGORICAL_LABEL_PX = 36; +// Labels longer than this rotate to -45°. +const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10; +// Middle-ellipsis cap for rotated labels (bounds axis height). +const CATEGORICAL_ROTATED_MAX_CHARS = 14; +// Rotated labels pack tighter than horizontal ones. +const CATEGORICAL_ROTATED_LABEL_PX = 32; +const CATEGORICAL_ROTATED_HEIGHT_PX = 80; +const MIN_CATEGORICAL_TICKS = 2; + +/** + * Shorten to `maxChars` with a middle ellipsis (e.g. "run_abc…f9c2"), preserving + * the distinguishing tail for IDs that share a prefix. + */ +export function truncateMiddle(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + if (maxChars <= 1) return value.slice(0, Math.max(0, maxChars)); + const keep = maxChars - 1; // room for the ellipsis + const head = Math.ceil(keep / 2); + const tail = Math.floor(keep / 2); + return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; +} + interface QueryResultsChartProps { rows: Record[]; columns: OutputColumnMetadata[]; @@ -307,7 +342,7 @@ const NICE_TIME_INTERVALS = [ * Generate evenly-spaced tick values for a time axis using "nice" intervals * that align to natural time boundaries (midnight, noon, hour marks, etc.) */ -function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] { +export function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] { const range = maxTime - minTime; if (range <= 0) { @@ -793,6 +828,22 @@ export const QueryResultsChart = memo(function QueryResultsChart({ timeTicks, } = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]); + // Measure the chart width so x-axis label density adapts to it (continuous time + // scale is kept, so data gaps still show). + const [chartMeasureRef, { width: chartWidth }] = useMeasure(); + + // Width-aware time-axis ticks: choose how many clean intervals to render based + // on width. Falls back to the default ticks until width is known (first paint). + const widthAwareTimeTicks = useMemo(() => { + if (!isDateBased || !timeDomain || !chartWidth) return timeTicks; + const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX); + const maxTicks = Math.max( + MIN_TIME_AXIS_TICKS, + Math.floor(plotWidth / TIME_AXIS_LABEL_SPACING_PX) + ); + return generateTimeTicks(timeDomain[0], timeDomain[1], maxTicks); + }, [isDateBased, timeDomain, timeTicks, chartWidth]); + // Apply sorting (for date-based, sort by timestamp to ensure correct order) const data = useMemo(() => { if (isDateBased) { @@ -1022,6 +1073,41 @@ export const QueryResultsChart = memo(function QueryResultsChart({ }; }, [isDateBased, xAxisTickFormatter, xAxisAngle]); + // Categorical x-axis: thin to fit width, middle-truncate long values, rotate when long. + const categoricalXAxisProps = useMemo(() => { + if (isDateBased) return null; + + const labels = data.map((d) => String(d[xDataKey] ?? "")); + const maxLabelChars = labels.reduce((max, l) => Math.max(max, l.length), 0); + const angled = maxLabelChars > CATEGORICAL_HORIZONTAL_MAX_CHARS; + + const tickFormatter = angled + ? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS) + : (value: unknown) => String(value ?? ""); + + // Rotated labels pack tighter; horizontal ones need roughly their own width. + const perLabelPx = angled + ? CATEGORICAL_ROTATED_LABEL_PX + : Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX); + const plotWidth = chartWidth > 0 ? Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX) : 0; + const fitCount = + plotWidth > 0 + ? Math.max(MIN_CATEGORICAL_TICKS, Math.floor(plotWidth / perLabelPx)) + : data.length; + const ticks = + fitCount < data.length + ? selectEvenlySpacedIndices(data.length, fitCount).map((i) => data[i][xDataKey] as string) + : undefined; + + return { + tickFormatter, + angle: angled ? -45 : 0, + textAnchor: angled ? ("end" as const) : ("middle" as const), + height: angled ? CATEGORICAL_ROTATED_HEIGHT_PX : undefined, + ...(ticks ? { ticks, interval: 0 as const } : {}), + }; + }, [isDateBased, data, xDataKey, chartWidth]); + // Validation — all hooks must be above this point const chartIcon = chartType === "bar" ? BarChart3 : LineChart; @@ -1070,15 +1156,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({ domain: timeDomain ?? (["auto", "auto"] as [string, string]), scale: "time" as const, // Explicitly specify tick positions so labels appear across the entire range - ticks: timeTicks ?? undefined, + ticks: widthAwareTimeTicks ?? undefined, ...baseXAxisProps, } - : baseXAxisProps; + : (categoricalXAxisProps ?? baseXAxisProps); // Bar charts always use categorical axis positioning // This ensures bars are evenly distributed regardless of data point count // (prevents massive bars when there are only a few data points) - const xAxisPropsForBar = baseXAxisProps; + const xAxisPropsForBar = isDateBased ? baseXAxisProps : (categoricalXAxisProps ?? baseXAxisProps); const yAxisProps = { tickFormatter: yAxisFormatter, @@ -1089,6 +1175,40 @@ export const QueryResultsChart = memo(function QueryResultsChart({ if (chartType === "bar") { return ( +
+ + + +
+ ); + } + + // Line or stacked area chart + return ( +
- 1} tooltipLabelFormatter={tooltipLabelFormatter} tooltipValueFormatter={tooltipValueFormatter} + lineType="linear" /> - ); - } - - // Line or stacked area chart - return ( - - 1} - tooltipLabelFormatter={tooltipLabelFormatter} - tooltipValueFormatter={tooltipValueFormatter} - lineType="linear" - /> - +
); }); diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 9990319da4d..53c20fbfde9 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -11,12 +11,37 @@ import { type YAxisProps, } from "recharts"; import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; import { useChartContext } from "./ChartContext"; import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; -import { useYAxisWidth } from "./useYAxisWidth"; +import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; +import { useXAxisTicks } from "./useXAxisTicks"; +import { useChartSync } from "./ChartSyncContext"; import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; +// charcoal-500: dashed line mirroring the hovered x across synced charts. +const SYNC_LINE_COLOR = "#5F6570"; + +// Shared with ChartLine so bar/line align when toggling. Right margin keeps the +// centered last x-axis label from clipping; bottom gives angled labels room. +export const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; + +/** While drag-to-zooming, show the selected From/To range instead of hovered values. */ +function ZoomRangeTooltip({ active, from, to }: { active?: boolean; from: string; to: string }) { + if (!active) return null; + return ( + +
+ From: + {from} + To: + {to} +
+
+ ); +} + //TODO: fix the first and last bars in a stack not having rounded corners type ReferenceLineProps = { @@ -86,8 +111,28 @@ export function ChartBarRenderer({ } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); + const sync = useChartSync(); const enableZoom = zoom !== null; - const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisPropsProp?.tickFormatter); + const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; + const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); + + // Width-aware horizontal labels, but only when the caller isn't already + // controlling ticks/interval/angle (e.g. the query widget's angled axes). + const callerControlsXTicks = + xAxisPropsProp?.ticks !== undefined || + xAxisPropsProp?.interval !== undefined || + xAxisPropsProp?.angle !== undefined; + // Plot width = full width minus the y-axis and horizontal margins. + const xAxisPlotWidth = + width != null + ? Math.max(0, width - computedYAxisWidth - CHART_MARGIN.left - CHART_MARGIN.right) + : undefined; + const autoXTicks = useXAxisTicks( + data, + dataKey, + xAxisPlotWidth, + xAxisPropsProp?.tickFormatter as ((value: any, index: number) => string) | undefined + ); const handleBarClick = useCallback( (barData: any, e: React.MouseEvent) => { @@ -101,11 +146,13 @@ export function ChartBarRenderer({ [enableZoom, zoom, dataKey] ); - // Handle mouse leave to also reset highlight + // Reset highlight and cancel any in-progress zoom drag on leave. const handleMouseLeave = useCallback(() => { zoomHandlers.onMouseLeave?.(); highlight.reset(); - }, [zoomHandlers, highlight]); + sync?.setActiveX(null); + sync?.cancelZoom(); + }, [zoomHandlers, highlight, sync]); // Render loading/error states if (state === "loading") { @@ -116,30 +163,68 @@ export function ChartBarRenderer({ return ; } - // Get the x-axis ticks based on tooltip state - // Only hide middle ticks when zoom is enabled (to make room for zoom instructions) - const xAxisTicks = + // When zoom is enabled, collapse to first/last ticks during hover to make room + // for the zoom instructions. + const zoomXAxisTicks = enableZoom && highlight.tooltipActive && data.length > 2 ? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]] : undefined; + // Zoom ticks win; otherwise use width-aware auto ticks unless the caller + // controls tick placement. + const useAutoXTicks = !callerControlsXTicks && !zoomXAxisTicks && autoXTicks != null; + const baseXTicks = zoomXAxisTicks ?? (useAutoXTicks ? autoXTicks : undefined); + const baseXInterval = useAutoXTicks ? 0 : ("preserveStartEnd" as const); + + const syncActiveX = sync?.activeX ?? null; + const syncZoomSelection = sync?.zoomSelection ?? null; + // Bucket width so the committed zoom range includes the last selected bucket. + const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; + + // Reuse the tooltip label formatter for the From/To edges (it reads `bucket` off the payload). + const formatZoomEdge = (v: number): string => + tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v); + let zoomFrom: string | null = null; + let zoomTo: string | null = null; + if (syncZoomSelection) { + const a = Number(syncZoomSelection.start); + const b = Number(syncZoomSelection.current); + if (Number.isFinite(a) && Number.isFinite(b)) { + zoomFrom = formatZoomEdge(Math.min(a, b)); + zoomTo = formatZoomEdge(Math.max(a, b)); + } + } + return ( { + zoomHandlers.onMouseDown?.(e); + if (sync?.zoomEnabled && e?.activeLabel != null) sync.startZoom(e.activeLabel); + }} onMouseMove={(e: any) => { zoomHandlers.onMouseMove?.(e); + if (sync?.zoomEnabled && sync.zoomSelection && e?.activeLabel != null) { + sync.updateZoom(e.activeLabel); + } if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); + sync?.setActiveX(e.activeLabel ?? null); } else { highlight.setTooltipActive(false); + sync?.setActiveX(null); } }} - onMouseUp={zoomHandlers.onMouseUp} + onMouseUp={() => { + zoomHandlers.onMouseUp?.(); + if (sync?.zoomEnabled) sync.endZoom(bucketWidthMs); + }} onClick={zoomHandlers.onClick} onMouseLeave={handleMouseLeave} > @@ -149,8 +234,8 @@ export function ChartBarRenderer({ tickLine={false} tickMargin={10} axisLine={false} - ticks={xAxisTicks} - interval="preserveStartEnd" + ticks={baseXTicks} + interval={baseXInterval} tick={{ fill: "#878C99", fontSize: 11, @@ -168,6 +253,7 @@ export function ChartBarRenderer({ fontSize: 11, style: { fontVariantNumeric: "tabular-nums" }, }} + tickFormatter={yAxisTickFormatter} domain={["auto", (dataMax: number) => dataMax * 1.15]} {...yAxisPropsProp} /> @@ -176,7 +262,9 @@ export function ChartBarRenderer({ + ) : showLegend ? ( () => null ) : tooltipLabelFormatter ? ( @@ -238,6 +326,33 @@ export function ChartBarRenderer({ ); })} + {/* Synced drag-to-zoom selection — mirrored across charts in the same group. */} + {syncZoomSelection && ( + + )} + + {/* Synced hover indicator: drawn on the *other* charts only (the hovered one + shows its own cursor); pointer-events-none so it never steals hover. */} + {syncActiveX != null && !highlight.tooltipActive && ( + + )} + {/* Horizontal reference line */} {referenceLine && ( (null); + + // "v" toggles fullscreen for the hovered card. + useShortcutKeys({ + shortcut: { key: "v" }, + action: useCallback(() => { + const isHovered = containerRef.current?.matches(":hover"); + if (!isFullscreen && !isHovered) return; + setIsFullscreen((prev) => !prev); + }, [isFullscreen]), + disabled: !maximizable, + }); + + return ( +
+ + +
{title}
+ {maximizable && ( + + +
+ ); +} diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index fa025f49402..e93a1bf35a9 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -19,7 +19,8 @@ import { import { ChartLineLoading, ChartLineNoData, ChartLineInvalid } from "./ChartLoading"; import { useChartContext } from "./ChartContext"; import { ChartRoot, useHasNoData } from "./ChartRoot"; -import { useYAxisWidth } from "./useYAxisWidth"; +import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; +import { CHART_MARGIN } from "./ChartBar"; // Legend is now rendered by ChartRoot outside the chart container import type { ZoomRange } from "./hooks/useZoomSelection"; @@ -94,7 +95,8 @@ export function ChartLineRenderer({ showLegend, } = useChartContext(); const hasNoData = useHasNoData(); - const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisPropsProp?.tickFormatter); + const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; + const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); // Render loading/error states if (state === "loading") { @@ -136,6 +138,7 @@ export function ChartLineRenderer({ fontSize: 11, style: { fontVariantNumeric: "tabular-nums" }, }, + tickFormatter: yAxisTickFormatter, ...yAxisPropsProp, }; @@ -153,10 +156,7 @@ export function ChartLineRenderer({ width={width} height={height} stackOffset="none" - margin={{ - left: 12, - right: 12, - }} + margin={CHART_MARGIN} onMouseMove={(e: any) => { if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); @@ -206,11 +206,7 @@ export function ChartLineRenderer({ data={data} width={width} height={height} - margin={{ - top: 5, - left: 12, - right: 12, - }} + margin={CHART_MARGIN} onMouseMove={(e: any) => { if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); diff --git a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx new file mode 100644 index 00000000000..ef01e1ded8b --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx @@ -0,0 +1,121 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; + +/** + * Cross-chart sync for a group of charts sharing an x-axis domain. Wrap them in one + * ; both behaviours mirror across every chart: a hover indicator + * (vertical line at the hovered x), and drag-to-zoom (a selection rectangle that + * commits the range via `onZoom`, e.g. to set the Time/Date filter). + * + * Chart.Bar reads this via useChartSync, a no-op when no provider is present. + * Drag-to-zoom is active only when `onZoom` is provided. + */ + +type ChartSyncXValue = number | string | null; + +/** Committed zoom range (epoch ms). */ +export type ChartZoomRange = { start: number; end: number }; + +/** In-progress drag selection (raw x values; not yet ordered). */ +type ZoomSelection = { start: number | string; current: number | string }; + +type ChartSyncContextValue = { + /** The x-axis value currently hovered in any chart in the group. */ + activeX: ChartSyncXValue; + setActiveX: (x: ChartSyncXValue) => void; + + /** Whether drag-to-zoom is active (an onZoom handler was provided). */ + zoomEnabled: boolean; + /** Current drag selection, mirrored across charts; null when not dragging. */ + zoomSelection: ZoomSelection | null; + startZoom: (x: number | string) => void; + updateZoom: (x: number | string) => void; + /** Finish the drag and commit the range (adds `bucketWidthMs` so the last bucket is included). */ + endZoom: (bucketWidthMs?: number) => void; + cancelZoom: () => void; +}; + +const ChartSyncContext = createContext(null); + +/** + * Turn a raw drag selection into an ordered, inclusive range. Returns null for + * a non-drag (start === current) or non-numeric selection. + */ +export function computeZoomRange( + start: number | string, + current: number | string, + bucketWidthMs = 0 +): ChartZoomRange | null { + const a = Number(start); + const b = Number(current); + if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return null; + const width = Number.isFinite(bucketWidthMs) ? bucketWidthMs : 0; + return { start: Math.min(a, b), end: Math.max(a, b) + width }; +} + +export function ChartSyncProvider({ + children, + onZoom, +}: { + children: React.ReactNode; + /** Called with the selected range when a drag-to-zoom completes. */ + onZoom?: (range: ChartZoomRange) => void; +}) { + const [activeX, setActiveXState] = useState(null); + const [zoomSelection, setZoomSelection] = useState(null); + // Track selection synchronously so endZoom (fired on mouseup) reads the latest. + const selectionRef = useRef(null); + + const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []); + + const startZoom = useCallback((x: number | string) => { + const next = { start: x, current: x }; + selectionRef.current = next; + setZoomSelection(next); + }, []); + + const updateZoom = useCallback((x: number | string) => { + const prev = selectionRef.current; + if (!prev) return; + const next = { start: prev.start, current: x }; + selectionRef.current = next; + setZoomSelection(next); + }, []); + + const cancelZoom = useCallback(() => { + selectionRef.current = null; + setZoomSelection(null); + }, []); + + const endZoom = useCallback( + (bucketWidthMs = 0) => { + const sel = selectionRef.current; + selectionRef.current = null; + setZoomSelection(null); + if (!sel) return; + const range = computeZoomRange(sel.start, sel.current, bucketWidthMs); + if (range) onZoom?.(range); + }, + [onZoom] + ); + + const value = useMemo( + () => ({ + activeX, + setActiveX, + zoomEnabled: onZoom != null, + zoomSelection, + startZoom, + updateZoom, + endZoom, + cancelZoom, + }), + [activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom] + ); + + return {children}; +} + +/** Returns the sync context, or null when not inside a ChartSyncProvider. */ +export function useChartSync(): ChartSyncContextValue | null { + return useContext(ChartSyncContext); +} diff --git a/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts new file mode 100644 index 00000000000..452988f44d7 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts @@ -0,0 +1,63 @@ +/** + * X-axis tick + tooltip label formatters for the task/agent activity charts. + * ClickHouse buckets are UTC-aligned, so labels are formatted in UTC (local time + * causes off-by-one day labels). Tick selection itself lives in useXAxisTicks. + */ + +const ONE_MINUTE = 60 * 1000; +const ONE_DAY = 24 * 60 * 60 * 1000; + +type ActivityPoint = { bucket: number }; + +export function buildActivityTimeAxis(data: ActivityPoint[]) { + const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; + const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; + + // ≤ 1 day range → clock time, otherwise date. + const showTime = range <= ONE_DAY; + // Sub-minute buckets need seconds, or adjacent ticks collapse to the same "HH:MM". + const showSeconds = bucketMs > 0 && bucketMs < ONE_MINUTE; + const isSubDayBucket = bucketMs > 0 && bucketMs < ONE_DAY; + + const tickFormatter = (value: number) => { + const date = new Date(value); + if (showTime) { + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + ...(showSeconds ? { second: "2-digit" } : {}), + hour12: false, + timeZone: "UTC", + }); + } + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + }; + + const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => { + const ts = payload?.[0]?.payload?.bucket; + if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; + const date = new Date(ts); + return isSubDayBucket + ? date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + ...(showSeconds ? { second: "2-digit" } : {}), + hour12: false, + timeZone: "UTC", + }) + : date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + }; + + return { tickFormatter, tooltipLabelFormatter }; +} diff --git a/apps/webapp/app/components/primitives/charts/statusColors.ts b/apps/webapp/app/components/primitives/charts/statusColors.ts new file mode 100644 index 00000000000..2ae7470a2d4 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/statusColors.ts @@ -0,0 +1,18 @@ +/** Shared status → color map for the task/agent activity charts. */ +export const STATUS_COLOR: Record = { + // Run-status groups + COMPLETED: "#28BF5C", + RUNNING: "#3B82F6", + FAILED: "#E11D48", + CANCELED: "#878C99", + // Agent session statuses + ACTIVE: "#3B82F6", + CLOSED: "#28BF5C", + EXPIRED: "#878C99", +}; + +export const STATUS_COLOR_FALLBACK = "#9CA3AF"; + +export function statusColor(status: string): string { + return STATUS_COLOR[status] ?? STATUS_COLOR_FALLBACK; +} diff --git a/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts new file mode 100644 index 00000000000..e6e16086438 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts @@ -0,0 +1,99 @@ +import { useMemo } from "react"; + +// At 11px tabular-nums, 1 char ≈ 6.5px, so character count is a width proxy (see useYAxisWidth). +const PX_PER_CH = 6.5; +// Minimum gap between adjacent labels. +const LABEL_GAP_PX = 16; +// Floor so very short labels still get some space. +const MIN_LABEL_PX = 24; + +/** Pick `count` indices evenly spaced across [0, n), always including the first and last. */ +export function selectEvenlySpacedIndices(n: number, count: number): number[] { + if (n <= 0) return []; + if (count <= 1) return [0]; + if (count >= n) return Array.from({ length: n }, (_, i) => i); + if (count === 2) return [0, n - 1]; + + const step = (n - 1) / (count - 1); + const out: number[] = []; + const seen = new Set(); + for (let i = 0; i < count; i++) { + const idx = Math.round(i * step); + if (!seen.has(idx)) { + seen.add(idx); + out.push(idx); + } + } + // Rounding can drop the final index; force it in. + if (!seen.has(n - 1)) out.push(n - 1); + return out; +} + +/** Pick `maxLabels` values evenly spaced across `values`, always including the first and last. */ +export function selectEvenlySpacedTicks(values: T[], maxLabels: number): T[] { + return selectEvenlySpacedIndices(values.length, maxLabels).map((i) => values[i]); +} + +/** How many labels of `maxLabelChars` width fit in `width` pixels. */ +export function estimateMaxLabels(width: number, maxLabelChars: number): number { + if (!width || width <= 0) return 0; + const labelPx = Math.max(MIN_LABEL_PX, maxLabelChars * PX_PER_CH) + LABEL_GAP_PX; + return Math.max(1, Math.floor(width / labelPx)); +} + +/** + * From `indices` (into `labels`/`values`), return the tick values with adjacent + * duplicate labels dropped. The final index is always kept: if it repeats the + * previous label it replaces that tick instead of being skipped, so the + * end-of-range label never disappears (honoring the "first + last" contract). + */ +export function dedupeTicksByLabel(indices: number[], labels: string[], values: T[]): T[] { + const lastIndex = values.length - 1; + const ticks: T[] = []; + let lastLabel: string | null = null; + for (const idx of indices) { + if (labels[idx] === lastLabel) { + if (idx === lastIndex && ticks.length > 0) ticks[ticks.length - 1] = values[idx]; + continue; + } + lastLabel = labels[idx]; + ticks.push(values[idx]); + } + return ticks; +} + +/** + * Explicit x-axis tick values: evenly spaced across the plot, including first + + * last, bounded by how many fit in `plotWidth` and by the count of distinct + * labels (so nothing overlaps or repeats). `plotWidth` excludes the y-axis and + * margins. Returns `undefined` until a width is known (first paint). + */ +export function useXAxisTicks( + data: Array>, + dataKey: string, + plotWidth: number | undefined, + tickFormatter?: (value: any, index: number) => string +): any[] | undefined { + return useMemo(() => { + if (!data?.length || !plotWidth || plotWidth <= 0) return undefined; + + const n = data.length; + const fmt = tickFormatter ?? ((v: any) => String(v)); + const labels = data.map((d, i) => fmt(d[dataKey], i) ?? ""); + + let maxChars = 0; + for (const label of labels) { + if (label.length > maxChars) maxChars = label.length; + } + + // Cap at distinct labels: laying out N evenly-spaced labels (vs one-per-period) + // keeps spacing even when the first/last period is partial. + const fit = estimateMaxLabels(plotWidth, maxChars); + const distinct = new Set(labels).size; + const target = Math.min(fit, distinct, n); + + // Evenly spaced on screen, then drop any that repeat the previous label. + const values = data.map((d) => d[dataKey]); + return dedupeTicksByLabel(selectEvenlySpacedIndices(n, target), labels, values); + }, [data, dataKey, plotWidth, tickFormatter]); +} diff --git a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts index 828b49c1c45..fb7d1abcf95 100644 --- a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts +++ b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts @@ -1,4 +1,12 @@ import { useMemo } from "react"; +import { formatNumberCompact } from "~/utils/numberFormatter"; + +/** + * Default y-axis tick formatter: compact notation (8000 → "8K", 1_200_000 → "1.2M"). + * Chart.Bar / Chart.Line use it unless the caller supplies its own tickFormatter. + */ +export const defaultYAxisTickFormatter = (value: any): string => + typeof value === "number" ? formatNumberCompact(value) : String(value); // 1ch at 11px tabular-nums system-ui ≈ 6.5px. Recharts' YAxis.width prop is a // raw number (pixels), so we can't use the CSS `ch` unit directly — but tabular-nums diff --git a/apps/webapp/app/hooks/useZoomToTimeFilter.ts b/apps/webapp/app/hooks/useZoomToTimeFilter.ts new file mode 100644 index 00000000000..b80318f6362 --- /dev/null +++ b/apps/webapp/app/hooks/useZoomToTimeFilter.ts @@ -0,0 +1,25 @@ +import { useCallback } from "react"; +import { type ChartZoomRange } from "~/components/primitives/charts/ChartSyncContext"; +import { useSearchParams } from "~/hooks/useSearchParam"; + +/** + * `onZoom` handler that sets the Time/Date filter to the dragged range, the same + * way TimeFilter applies a custom range: epoch-ms `from`/`to`, clearing `period` + * and pagination so the page reloads scoped to that window. + */ +export function useZoomToTimeFilter() { + const { replace } = useSearchParams(); + + return useCallback( + (range: ChartZoomRange) => { + replace({ + period: undefined, + cursor: undefined, + direction: undefined, + from: Math.round(range.start).toString(), + to: Math.round(range.end).toString(), + }); + }, + [replace] + ); +} diff --git a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts index 0d789539fb7..5d4fa6914f8 100644 --- a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts @@ -2,6 +2,13 @@ import { type ClickHouse } from "@internal/clickhouse"; import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { z } from "zod"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { + chooseBucketSeconds, + groupRunStatus, + RUN_STATUS_GROUPS, + zeroFillGroupedSeries, + zeroFillScalarSeries, +} from "./activitySeries.server"; export type AgentDetail = { slug: string; @@ -20,33 +27,6 @@ export type AgentActivity = { statuses: string[]; }; -const TERMINAL_GROUPS = { - COMPLETED: ["COMPLETED_SUCCESSFULLY"], - FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"], - CANCELED: ["CANCELED", "EXPIRED"], - RUNNING: [ - "EXECUTING", - "DEQUEUED", - "PENDING_EXECUTING", - "WAITING_TO_RESUME", - "QUEUED_EXECUTING", - "PENDING", - "PENDING_VERSION", - "DELAYED", - "WAITING_FOR_DEPLOY", - ], -} as const; - -const GROUP_LABEL = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; -type GroupLabel = (typeof GROUP_LABEL)[number]; - -function groupForStatus(status: string): GroupLabel | undefined { - for (const label of GROUP_LABEL) { - if ((TERMINAL_GROUPS[label] as readonly string[]).includes(status)) return label; - } - return undefined; -} - // Stable legend order for the sessions activity chart. Derived statuses: // ACTIVE = closed_at IS NULL AND (expires_at IS NULL OR expires_at > now) // CLOSED = closed_at IS NOT NULL @@ -117,17 +97,7 @@ export class AgentDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const sixHours = 6 * oneHour; - const oneDay = 24 * oneHour; - - // Pick a sensible bucket interval based on the range - const bucketSeconds = - rangeMs <= oneDay - ? 60 * 60 // 1h buckets - : rangeMs <= 7 * oneDay - ? 6 * 60 * 60 // 6h buckets - : 24 * 60 * 60; // 1d buckets + const bucketSeconds = chooseBucketSeconds(rangeMs); // NOTE: We intentionally don't filter by `task_kind = 'AGENT'` here: // ClickHouse stores `task_kind = ""` for pre-migration rows and rows @@ -186,33 +156,19 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const group = groupForStatus(row.status) ?? "RUNNING"; - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[group] = (existing[group] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - // Build zero-filled time series. We always emit every status group so - // the chart legend is stable across time ranges (even when a group has - // no runs in the current window). - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; - const orderedStatuses = [...GROUP_LABEL]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: AgentActivityPoint = { bucket: ts }; - for (const g of orderedStatuses) { - point[g] = existing[g] ?? 0; - } - points.push(point); - } + // Always emit every status group so the chart legend is stable across time + // ranges (even when a group has no runs in the current window). + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: RUN_STATUS_GROUPS, + groupFn: groupRunStatus, + fallbackKey: "RUNNING", + }); - return { data: points, statuses: orderedStatuses }; + return { data: points, statuses: [...RUN_STATUS_GROUPS] }; } async getSessionActivity({ @@ -231,11 +187,7 @@ export class AgentDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay ? 60 * 60 : rangeMs <= 7 * oneDay ? 6 * 60 * 60 : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); // FINAL collapses ReplacingMergeTree versions so we see each session's // latest state — important since closed_at / expires_at are mutated @@ -291,27 +243,14 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[row.status] = (existing[row.status] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; const orderedStatuses: SessionStatusLabel[] = [...SESSION_STATUSES]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: AgentActivityPoint = { bucket: ts }; - for (const s of orderedStatuses) { - point[s] = existing[s] ?? 0; - } - points.push(point); - } + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: orderedStatuses, + }); return { data: points, statuses: orderedStatuses }; } @@ -356,11 +295,7 @@ export class AgentDetailPresenter { metric: "cost" | "tokens"; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay ? 60 * 60 : rangeMs <= 7 * oneDay ? 6 * 60 * 60 : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); const seriesKey = metric === "cost" ? "cost" : "tokens"; // total_cost is Decimal64(12); cast to Float64 so the JSON wire format is @@ -417,19 +352,7 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map(); - for (const row of rows) { - const ts = row.bucket * 1000; - bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val); - } - - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; - for (let ts = start; ts < end; ts += bucketMs) { - points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 }); - } + const points = zeroFillScalarSeries({ rows, from, to, bucketSeconds, seriesKey }); return { data: points, statuses: [seriesKey] }; } diff --git a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts index ac3857b1ebb..d4bd38cf643 100644 --- a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts @@ -8,6 +8,12 @@ import { import { z } from "zod"; import { machinePresetFromConfig } from "~/v3/machinePresets.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { + chooseBucketSeconds, + groupRunStatus, + RUN_STATUS_GROUPS, + zeroFillGroupedSeries, +} from "./activitySeries.server"; export type TaskDetailQueue = { friendlyId: string; @@ -50,33 +56,6 @@ export type TaskActivity = { statuses: string[]; }; -const TERMINAL_GROUPS = { - COMPLETED: ["COMPLETED_SUCCESSFULLY"], - FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"], - CANCELED: ["CANCELED", "EXPIRED"], - RUNNING: [ - "EXECUTING", - "DEQUEUED", - "PENDING_EXECUTING", - "WAITING_TO_RESUME", - "QUEUED_EXECUTING", - "PENDING", - "PENDING_VERSION", - "DELAYED", - "WAITING_FOR_DEPLOY", - ], -} as const; - -const GROUP_LABEL = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; -type GroupLabel = (typeof GROUP_LABEL)[number]; - -function groupForStatus(status: string): GroupLabel | undefined { - for (const label of GROUP_LABEL) { - if ((TERMINAL_GROUPS[label] as readonly string[]).includes(status)) return label; - } - return undefined; -} - export class TaskDetailPresenter { constructor( private readonly replica: PrismaClientOrTransaction, @@ -178,11 +157,7 @@ export class TaskDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay ? 60 * 60 : rangeMs <= 7 * oneDay ? 6 * 60 * 60 : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); // FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree; // org/project filters engage the sort-key prefix for partition pruning. @@ -235,31 +210,18 @@ export class TaskDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const group = groupForStatus(row.status) ?? "RUNNING"; - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[group] = (existing[group] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - // Always emit every status group so the chart legend is stable across - // time ranges (even when a group has no runs in the current window). - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: TaskActivityPoint[] = []; - const orderedStatuses = [...GROUP_LABEL]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: TaskActivityPoint = { bucket: ts }; - for (const g of orderedStatuses) { - point[g] = existing[g] ?? 0; - } - points.push(point); - } + // Always emit every status group so the chart legend is stable across time + // ranges (even when a group has no runs in the current window). + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: RUN_STATUS_GROUPS, + groupFn: groupRunStatus, + fallbackKey: "RUNNING", + }); - return { data: points, statuses: orderedStatuses }; + return { data: points, statuses: [...RUN_STATUS_GROUPS] }; } } diff --git a/apps/webapp/app/presenters/v3/activitySeries.server.ts b/apps/webapp/app/presenters/v3/activitySeries.server.ts new file mode 100644 index 00000000000..ba2cbe322e5 --- /dev/null +++ b/apps/webapp/app/presenters/v3/activitySeries.server.ts @@ -0,0 +1,157 @@ +/** Shared bucketing + zero-fill helpers for the task/agent activity bar charts. */ + +// Snap bucket intervals to human-friendly values (1s…7d) so tick boundaries stay readable. +const NICE_BUCKET_SECONDS = [ + 1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, + 604800, +] as const; + +export type ChooseBucketOptions = { + /** Bucket count to aim for (a "full"-looking chart). */ + targetBuckets?: number; + /** Hard ceiling so we never emit sub-pixel bars or huge result sets. */ + maxBuckets?: number; +}; + +/** + * Pick a bucket interval (seconds): the nice value whose bucket count is closest + * to `targetBuckets` without exceeding `maxBuckets`. Keeps a 5-minute range from + * collapsing to a single 1-hour bar. + */ +export function chooseBucketSeconds( + rangeMs: number, + { targetBuckets = 72, maxBuckets = 120 }: ChooseBucketOptions = {} +): number { + const rangeSeconds = Math.max(1, Math.ceil(rangeMs / 1000)); + + let best: number | null = null; + let bestScore = Infinity; + for (const secs of NICE_BUCKET_SECONDS) { + const count = rangeSeconds / secs; + if (count > maxBuckets) continue; + const score = Math.abs(count - targetBuckets); + if (score < bestScore) { + bestScore = score; + best = secs; + } + } + + // Range larger than the ladder: derive an interval that respects the ceiling. + if (best === null) { + return Math.ceil(rangeSeconds / maxBuckets); + } + + return best; +} + +export const RUN_STATUS_GROUPS = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; +export type RunStatusGroup = (typeof RUN_STATUS_GROUPS)[number]; + +const TERMINAL_GROUPS: Record = { + COMPLETED: ["COMPLETED_SUCCESSFULLY"], + FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"], + CANCELED: ["CANCELED", "EXPIRED"], + RUNNING: [ + "EXECUTING", + "DEQUEUED", + "PENDING_EXECUTING", + "WAITING_TO_RESUME", + "QUEUED_EXECUTING", + "PENDING", + "PENDING_VERSION", + "DELAYED", + "WAITING_FOR_DEPLOY", + ], +}; + +/** Map a raw TaskRun status to one of the four chart groups. */ +export function groupRunStatus(status: string): RunStatusGroup | undefined { + for (const label of RUN_STATUS_GROUPS) { + if (TERMINAL_GROUPS[label].includes(status)) return label; + } + return undefined; +} + +export type ActivitySeriesPoint = { bucket: number } & Record; + +function bucketBounds(from: Date, to: Date, bucketSeconds: number) { + const bucketMs = bucketSeconds * 1000; + return { + bucketMs, + start: Math.floor(from.getTime() / bucketMs) * bucketMs, + end: Math.ceil(to.getTime() / bucketMs) * bucketMs, + }; +} + +/** + * Zero-filled grouped series: every bucket in [from, to) is emitted and every + * `orderedKeys` entry is present on each point, for contiguous bars and a stable + * legend. + */ +export function zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys, + groupFn, + fallbackKey, +}: { + rows: Array<{ bucket: number; status: string; val: number }>; + from: Date; + to: Date; + bucketSeconds: number; + orderedKeys: readonly K[]; + /** Maps a raw status to a key; defaults to identity. */ + groupFn?: (status: string) => K | undefined; + /** Key for statuses groupFn doesn't map (e.g. unknown statuses). */ + fallbackKey?: K; +}): ActivitySeriesPoint[] { + const bucketMap = new Map>(); + for (const row of rows) { + const key = (groupFn ? groupFn(row.status) : (row.status as K)) ?? fallbackKey; + if (!key) continue; + const ts = row.bucket * 1000; + const existing = bucketMap.get(ts) ?? {}; + existing[key] = (existing[key] ?? 0) + row.val; + bucketMap.set(ts, existing); + } + + const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds); + const points: ActivitySeriesPoint[] = []; + for (let ts = start; ts < end; ts += bucketMs) { + const existing = bucketMap.get(ts) ?? {}; + const point: ActivitySeriesPoint = { bucket: ts }; + for (const k of orderedKeys) point[k] = existing[k] ?? 0; + points.push(point); + } + return points; +} + +/** Zero-filled single-series (scalar) time series. */ +export function zeroFillScalarSeries({ + rows, + from, + to, + bucketSeconds, + seriesKey, +}: { + rows: Array<{ bucket: number; val: number }>; + from: Date; + to: Date; + bucketSeconds: number; + seriesKey: string; +}): ActivitySeriesPoint[] { + const bucketMap = new Map(); + for (const row of rows) { + const ts = row.bucket * 1000; + bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val); + } + + const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds); + const points: ActivitySeriesPoint[] = []; + for (let ts = start; ts < end; ts += bucketMs) { + points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 }); + } + return points; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index 839eeb3f771..75e2de21a96 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -1,7 +1,7 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { type ReactNode, Suspense, useMemo, useState } from "react"; +import { Suspense, useMemo, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; @@ -9,8 +9,12 @@ import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; import { PageBody } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; @@ -201,6 +205,7 @@ export default function Page() { const tasksPath = v3EnvironmentPath(organization, project, environment); const [tab, setTab] = useState("sessions"); + const zoomToTimeFilter = useZoomToTimeFilter(); const tabLabel = tab === "sessions" ? "Sessions" : "Runs"; return ( @@ -270,67 +275,69 @@ export default function Page() { {/* Activity / LLM cost / Token charts */}
-
- - {tab === "sessions" ? ( + +
+ + {tab === "sessions" ? ( + }> + } + > + {(result) => } + + + ) : ( + }> + } + > + {(result) => } + + + )} + + + }> } > - {(result) => } + {(result) => ( + + )} - ) : ( + + + }> } > - {(result) => } + {(result) => ( + + )} - )} - - - - }> - } - > - {(result) => ( - - )} - - - - - - }> - } - > - {(result) => ( - - )} - - - -
+
+
+
@@ -492,32 +499,20 @@ function AgentDetailSidebar({ ); } -const STATUS_COLOR: Record = { - // Run statuses - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", - // Session statuses - ACTIVE: "#3B82F6", - CLOSED: "#28BF5C", - EXPIRED: "#878C99", -}; - function ActivityChart({ activity }: { activity: AgentActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks, tooltipLabelFormatter } = useMemo( - () => buildTimeAxis(activity.data), + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), [activity.data] ); @@ -532,10 +527,7 @@ function ActivityChart({ activity }: { activity: AgentActivity }) { @@ -560,15 +552,6 @@ function TableLoading() { ); } -function ChartCard({ title, children }: { title: string; children: ReactNode }) { - return ( - - {title} -
{children}
-
- ); -} - function ScalarActivityChart({ activity, seriesKey, @@ -587,8 +570,8 @@ function ScalarActivityChart({ [seriesKey, label, color] ); - const { xAxisFormatter, xAxisTicks, tooltipLabelFormatter } = useMemo( - () => buildTimeAxis(activity.data), + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), [activity.data] ); @@ -596,10 +579,7 @@ function ScalarActivityChart({ @@ -607,58 +587,6 @@ function ScalarActivityChart({ ); } -function buildTimeAxis(data: AgentActivity["data"]) { - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - const xAxisFormatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - const xAxisTicks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - - return { xAxisFormatter, xAxisTicks, tooltipLabelFormatter }; -} - function formatCurrency(value: number): string { if (value === 0) return "$0"; if (value < 0.01) return `$${value.toFixed(4)}`; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 548bf6bf586..823d2a96e65 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -11,8 +11,12 @@ import { RunsIcon } from "~/assets/icons/RunsIcon"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { Dialog, DialogContent, @@ -191,6 +195,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { const { task, activity, scheduleList, runList } = useTypedLoaderData(); + const zoomToTimeFilter = useZoomToTimeFilter(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -296,16 +301,15 @@ export default function Page() { {/* Activity chart */}
- - Runs by status -
+ + }> }> {(result) => } -
-
+ +
@@ -979,81 +983,22 @@ function SchedulesMiniTable({ ); } -const STATUS_COLOR: Record = { - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", -}; - function ActivityChart({ activity }: { activity: TaskActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks } = useMemo(() => { - const data = activity.data; - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - const formatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - const ticks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - return { xAxisFormatter: formatter, xAxisTicks: ticks }; - }, [activity.data]); - - const tooltipLabelFormatter = useMemo(() => { - const data = activity.data; - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - return (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - }, [activity.data]); + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), + [activity.data] + ); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index 1e3d7500589..816f2fca957 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -10,8 +10,12 @@ import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; @@ -129,6 +133,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { const { task, activity, runList } = useTypedLoaderData(); + const zoomToTimeFilter = useZoomToTimeFilter(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -174,16 +179,15 @@ export default function Page() { {/* Activity chart */}
- - Runs by status -
+ + }> }> {(result) => } -
-
+ +
@@ -374,87 +378,22 @@ function formatRetrySummary(retry: TaskDetail["retry"]): string { return `${retry.maxAttempts} attempts`; } -const STATUS_COLOR: Record = { - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", -}; - function ActivityChart({ activity }: { activity: TaskActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks } = useMemo(() => { - const data = activity.data; - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - // ClickHouse buckets are aligned to UTC, so we format and pick ticks in - // UTC. Using local time here causes off-by-one day labels and a tick - // filter that matches zero buckets in any timezone other than UTC. - const formatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - // For multi-day ranges with sub-day buckets, only label the midnight - // bucket on each day so we don't get repeated date labels across the - // multiple sub-day buckets within a single day. - const ticks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - return { xAxisFormatter: formatter, xAxisTicks: ticks }; - }, [activity.data]); - - const tooltipLabelFormatter = useMemo(() => { - const data = activity.data; - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - return (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - }, [activity.data]); + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), + [activity.data] + ); return ( diff --git a/apps/webapp/test/activitySeries.server.test.ts b/apps/webapp/test/activitySeries.server.test.ts new file mode 100644 index 00000000000..d3e032f50be --- /dev/null +++ b/apps/webapp/test/activitySeries.server.test.ts @@ -0,0 +1,132 @@ +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); + }); +}); diff --git a/apps/webapp/test/chartActivityTimeAxis.test.ts b/apps/webapp/test/chartActivityTimeAxis.test.ts new file mode 100644 index 00000000000..413a8526518 --- /dev/null +++ b/apps/webapp/test/chartActivityTimeAxis.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function series(startISO: string, count: number, stepMs: number) { + const start = new Date(startISO).getTime(); + return Array.from({ length: count }, (_, i) => ({ bucket: start + i * stepMs })); +} + +describe("buildActivityTimeAxis", () => { + it("shows seconds in labels for sub-minute buckets", () => { + const data = series("2026-06-22T00:00:00.000Z", 6, 5 * SECOND); // 5s buckets, 25s span + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + // HH:MM:SS -> two colons + expect(label.split(":").length).toBe(3); + }); + + it("shows HH:MM (no seconds) for minute+ buckets within a day", () => { + const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE); // 30m buckets, 6h span + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + expect(label.split(":").length).toBe(2); + }); + + it("shows the date for multi-day ranges", () => { + const data = series("2026-06-20T00:00:00.000Z", 4, DAY); // 4 days + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + // e.g. "Jun 20" — no time component + expect(label).toMatch(/[A-Za-z]{3}\s+\d{1,2}/); + expect(label).not.toContain(":"); + }); + + it("formats labels in UTC", () => { + const data = series("2026-06-22T13:30:00.000Z", 4, 15 * MINUTE); + const { tickFormatter } = buildActivityTimeAxis(data); + expect(tickFormatter(data[0].bucket)).toBe("13:30"); + }); + + it("tooltip formatter reads the bucket from payload and includes date + time for sub-day buckets", () => { + const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE); + const { tooltipLabelFormatter } = buildActivityTimeAxis(data); + const label = tooltipLabelFormatter("", [{ payload: { bucket: data[0].bucket } }]); + expect(label).toContain("Jun"); + expect(label).toContain(":"); + }); + + it("tooltip formatter falls back to the raw label when no bucket is present", () => { + const data = series("2026-06-22T00:00:00.000Z", 2, 30 * MINUTE); + const { tooltipLabelFormatter } = buildActivityTimeAxis(data); + expect(tooltipLabelFormatter("fallback", [{}])).toBe("fallback"); + }); +}); diff --git a/apps/webapp/test/chartXAxisTicks.test.ts b/apps/webapp/test/chartXAxisTicks.test.ts new file mode 100644 index 00000000000..659d7d13018 --- /dev/null +++ b/apps/webapp/test/chartXAxisTicks.test.ts @@ -0,0 +1,124 @@ +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([]); + }); +}); diff --git a/apps/webapp/test/chartZoomRange.test.ts b/apps/webapp/test/chartZoomRange.test.ts new file mode 100644 index 00000000000..81fef105008 --- /dev/null +++ b/apps/webapp/test/chartZoomRange.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { computeZoomRange } from "~/components/primitives/charts/ChartSyncContext"; + +describe("computeZoomRange", () => { + it("orders the selection ascending regardless of drag direction", () => { + expect(computeZoomRange(300, 100)).toEqual({ start: 100, end: 300 }); + expect(computeZoomRange(100, 300)).toEqual({ start: 100, end: 300 }); + }); + + it("adds the bucket width so the last selected bucket is included", () => { + expect(computeZoomRange(100, 300, 60)).toEqual({ start: 100, end: 360 }); + }); + + it("returns null for a non-drag (start === current)", () => { + expect(computeZoomRange(100, 100)).toBeNull(); + expect(computeZoomRange(100, 100, 60)).toBeNull(); + }); + + it("returns null for non-numeric selections", () => { + expect(computeZoomRange("a", "b")).toBeNull(); + }); + + it("handles numeric-string category values from recharts", () => { + expect(computeZoomRange("100", "300", 60)).toEqual({ start: 100, end: 360 }); + }); +}); diff --git a/apps/webapp/test/queryResultsTimeTicks.test.ts b/apps/webapp/test/queryResultsTimeTicks.test.ts new file mode 100644 index 00000000000..c55228691c4 --- /dev/null +++ b/apps/webapp/test/queryResultsTimeTicks.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { generateTimeTicks, truncateMiddle } from "~/components/code/QueryResultsChart"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const min = new Date("2026-06-15T00:00:00.000Z").getTime(); +const max = new Date("2026-06-22T00:00:00.000Z").getTime(); // 7 days + +describe("generateTimeTicks (width-aware tick budget)", () => { + it("produces more ticks when a wider chart allows more to fit", () => { + const few = generateTimeTicks(min, max, 4); + const many = generateTimeTicks(min, max, 16); + expect(many.length).toBeGreaterThan(few.length); + }); + + it("stays near the budget for a narrow chart", () => { + // The loop targets <= maxTicks; edge ticks can add 1-2. + expect(generateTimeTicks(min, max, 4).length).toBeLessThanOrEqual(6); + }); + + it("keeps every tick within the range (plus a small edge margin)", () => { + for (const t of generateTimeTicks(min, max, 12)) { + expect(t).toBeGreaterThanOrEqual(min - DAY); + expect(t).toBeLessThanOrEqual(max + DAY); + } + }); + + it("ticks are sorted ascending", () => { + const ticks = generateTimeTicks(min, max, 12); + for (let i = 1; i < ticks.length; i++) { + expect(ticks[i]).toBeGreaterThan(ticks[i - 1]); + } + }); + + it("returns a single tick for a zero range", () => { + expect(generateTimeTicks(min, min, 8)).toEqual([min]); + }); +}); + +describe("truncateMiddle", () => { + it("leaves short strings unchanged", () => { + expect(truncateMiddle("run_abc", 14)).toBe("run_abc"); + }); + + it("keeps the head and tail with a middle ellipsis", () => { + expect(truncateMiddle("abcdefghijklmnop", 9)).toBe("abcd…mnop"); + }); + + it("never exceeds maxChars", () => { + const out = truncateMiddle("run_0123456789abcdef", 12); + expect(out.length).toBeLessThanOrEqual(12); + expect(out).toContain("…"); + }); + + it("preserves the distinguishing tail for ids sharing a prefix", () => { + const a = truncateMiddle("run_commonprefix_AAAA", 12); + const b = truncateMiddle("run_commonprefix_BBBB", 12); + expect(a).not.toBe(b); + }); +});