Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions apps/webapp/app/components/code/QueryResultsChart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { memo, useMemo } from "react";
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 { Paragraph } from "../primitives/Paragraph";
Expand Down Expand Up @@ -797,8 +800,24 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, [isDateBased, timeGranularity]);

// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Resolve the Y-axis column format for formatting
const yAxisFormat = useMemo(() => {
if (yAxisColumns.length === 0) return undefined;
const col = columns.find((c) => c.name === yAxisColumns[0]);
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
}, [yAxisColumns, columns]);

// Create dynamic Y-axis formatter based on data range and format
const yAxisFormatter = useMemo(
() => createYAxisFormatter(data, series, yAxisFormat),
[data, series, yAxisFormat]
);

// Create value formatter for tooltips and legend based on column format
const tooltipValueFormatter = useMemo(
() => createValueFormatter(yAxisFormat),
[yAxisFormat]
);

// Check if the group-by column has a runStatus customRenderType
const groupByIsRunStatus = useMemo(() => {
Expand Down Expand Up @@ -1016,6 +1035,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
Expand All @@ -1027,6 +1047,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
/>
</Chart.Root>
);
Expand All @@ -1043,6 +1064,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
Expand All @@ -1054,16 +1076,21 @@ export const QueryResultsChart = memo(function QueryResultsChart({
yAxisProps={yAxisProps}
stacked={stacked && sortedSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
);
});

/**
* Creates a Y-axis value formatter based on the data range
* Creates a Y-axis value formatter based on the data range and optional format hint
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
function createYAxisFormatter(
data: Record<string, unknown>[],
series: string[],
format?: ColumnFormatType
) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
Expand All @@ -1080,6 +1107,46 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])

const range = maxVal - minVal;

// Format-aware formatters
if (format === "bytes" || format === "decimalBytes") {
const divisor = format === "bytes" ? 1024 : 1000;
const units =
format === "bytes"
? ["B", "KiB", "MiB", "GiB", "TiB"]
: ["B", "KB", "MB", "GB", "TB"];
return (value: number): string => {
if (value === 0) return "0 B";
// Use consistent unit for all ticks based on max value
const i = Math.min(
Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor)),
units.length - 1
);
const scaled = value / Math.pow(divisor, i);
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
};
}
Comment on lines +1111 to +1127
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: maxVal could be -Infinity when the formatter is invoked with bytes format.

If maxVal remains -Infinity (e.g., all values are null/non-numeric), then Math.abs(maxVal || 1) evaluates to Math.abs(-Infinity) = Infinity because -Infinity is truthy. This makes i clamp to the last unit index, so a non-zero value would be formatted as e.g. "0.0 TiB" instead of "0 B". In practice, the early returns at lines 977–983 prevent rendering an empty chart, so this is unlikely to trigger. But a small guard would make the formatter more robust.

Proposed defensive fix
     return (value: number): string => {
       if (value === 0) return "0 B";
       // Use consistent unit for all ticks based on max value
+      const refValue = isFinite(maxVal) ? Math.abs(maxVal) : Math.abs(value);
       const i = Math.min(
-        Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor)),
+        Math.floor(Math.log(refValue || 1) / Math.log(divisor)),
         units.length - 1
       );
🤖 Prompt for AI Agents
In `@apps/webapp/app/components/code/QueryResultsChart.tsx` around lines 1111 -
1127, The bytes formatter block in QueryResultsChart.tsx can mis-handle maxVal
when it's -Infinity; update the formatter to guard against non-finite maxVal by
computing a safeMax (e.g., const safeMax = Number.isFinite(maxVal) ?
Math.abs(maxVal) : 1) and use safeMax in the log/division math instead of
Math.abs(maxVal || 1), keeping the existing value === 0 early return and the
rest of the scaling logic (divisor, units, i calculation, scaled, return string)
unchanged.


if (format === "percent") {
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
}

if (format === "duration") {
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
}

if (format === "durationSeconds") {
return (value: number): string =>
formatDurationMilliseconds(value * 1000, { style: "short" });
}

if (format === "costInDollars" || format === "cost") {
return (value: number): string => {
const dollars = format === "cost" ? value / 100 : value;
return formatCurrencyAccurate(dollars);
};
}

// Default formatter
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
Expand Down
73 changes: 70 additions & 3 deletions apps/webapp/app/components/code/TSQLResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useCopy } from "~/hooks/useCopy";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { Paragraph } from "../primitives/Paragraph";
Expand Down Expand Up @@ -64,9 +65,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
if (value === null) return "NULL";
if (value === undefined) return "";

// Handle custom render types
if (column.customRenderType) {
switch (column.customRenderType) {
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
const formatType = column.format ?? column.customRenderType;
if (formatType) {
switch (formatType) {
case "duration":
if (typeof value === "number") {
return formatDurationMilliseconds(value, { style: "short" });
Expand All @@ -93,6 +95,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return value;
}
break;
case "bytes":
if (typeof value === "number") {
return formatBytes(value);
}
break;
case "decimalBytes":
if (typeof value === "number") {
return formatDecimalBytes(value);
}
break;
case "percent":
if (typeof value === "number") {
return `${value.toFixed(2)}%`;
}
break;
case "quantity":
if (typeof value === "number") {
return formatQuantity(value);
}
break;
}
}

Expand Down Expand Up @@ -220,6 +242,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
if (value === null) return 4; // "NULL"
if (value === undefined) return 9; // "UNDEFINED"

// Handle format hint types - estimate their rendered width
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes") {
// e.g., "1.50 GiB" or "256.00 MB"
return 12;
}
if (fmt === "percent") {
// e.g., "45.23%"
return 8;
}
if (fmt === "quantity") {
// e.g., "1.50M"
return 8;
}

// Handle custom render types - estimate their rendered width
if (column.customRenderType) {
switch (column.customRenderType) {
Expand Down Expand Up @@ -392,6 +429,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
) {
return true;
}
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
return true;
}
return isNumericType(column.type);
}

Expand Down Expand Up @@ -474,6 +515,32 @@ function CellValue({
return <pre className="text-text-dimmed">UNDEFINED</pre>;
}

// Check format hint for new format types (from prettyFormat())
if (column.format && !column.customRenderType) {
switch (column.format) {
case "bytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatBytes(value)}</span>;
}
break;
case "decimalBytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
}
break;
case "percent":
if (typeof value === "number") {
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
}
break;
case "quantity":
if (typeof value === "number") {
return <span className="tabular-nums">{formatQuantity(value)}</span>;
}
break;
}
}
Comment on lines +518 to +542
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

prettyFormat hints are skipped when customRenderType exists (and duration/cost formats are ignored).
The format-hint block only runs when !column.customRenderType and only handles bytes/decimal/percent/quantity. That means prettyFormat(..., "duration" | "cost") or overrides on columns with customRenderType won’t render as intended. Prefer column.format and handle all supported formats.

Suggested change
-  if (column.format && !column.customRenderType) {
+  if (column.format) {
     switch (column.format) {
       case "bytes":
         if (typeof value === "number") {
           return <span className="tabular-nums">{formatBytes(value)}</span>;
         }
         break;
       case "decimalBytes":
         if (typeof value === "number") {
           return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
         }
         break;
       case "percent":
         if (typeof value === "number") {
           return <span className="tabular-nums">{value.toFixed(2)}%</span>;
         }
         break;
       case "quantity":
         if (typeof value === "number") {
           return <span className="tabular-nums">{formatQuantity(value)}</span>;
         }
         break;
+      case "duration":
+        if (typeof value === "number") {
+          return (
+            <span className="tabular-nums">
+              {formatDurationMilliseconds(value, { style: "short" })}
+            </span>
+          );
+        }
+        break;
+      case "durationSeconds":
+        if (typeof value === "number") {
+          return (
+            <span className="tabular-nums">
+              {formatDurationMilliseconds(value * 1000, { style: "short" })}
+            </span>
+          );
+        }
+        break;
+      case "costInDollars":
+        if (typeof value === "number") {
+          return <span className="tabular-nums">{formatCurrencyAccurate(value)}</span>;
+        }
+        break;
+      case "cost":
+        if (typeof value === "number") {
+          return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
+        }
+        break;
     }
   }
🤖 Prompt for AI Agents
In `@apps/webapp/app/components/code/TSQLResultsTable.tsx` around lines 518 - 542,
The current format-hint block is skipped whenever column.customRenderType is set
and also only handles bytes/decimalBytes/percent/quantity, causing prettyFormat
hints like "duration" or "cost" (and any overrides on columns with
customRenderType) to be ignored; update the conditional to always check
column.format regardless of column.customRenderType and extend the switch in the
format handling (the block that checks column.format) to include "duration" and
"cost" cases (using the existing formatBytes/formatDecimalBytes/formatQuantity
helpers as a model—call the appropriate formatDuration/formatCost utilities or
create them if missing) so that prettyFormat hints are applied consistently.


// First check customRenderType for special rendering
if (column.customRenderType) {
switch (column.customRenderType) {
Expand Down
27 changes: 26 additions & 1 deletion apps/webapp/app/components/primitives/charts/BigNumberCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { OutputColumnMetadata } from "@internal/tsql";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { createValueFormatter } from "~/utils/columnFormat";
import { AnimatedNumber } from "../AnimatedNumber";
import { Spinner } from "../Spinner";
import { Paragraph } from "../Paragraph";
Expand Down Expand Up @@ -129,6 +130,15 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);

// Look up column format for format-aware display
const columnValueFormatter = useMemo(() => {
const columnMeta = columns.find((c) => c.name === column);
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
| ColumnFormatType
| undefined;
return createValueFormatter(formatType);
}, [columns, column]);

if (isLoading) {
return (
<div className="grid h-full place-items-center [container-type:size]">
Expand All @@ -147,6 +157,21 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
);
}

// Use format-aware formatter when available
if (columnValueFormatter) {
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap font-normal tabular-nums leading-none text-text-bright text-[clamp(24px,12cqw,96px)]">
{prefix && <span>{prefix}</span>}
<span>{columnValueFormatter(result)}</span>
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
</div>
</div>
</div>
);
}

const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
Expand Down
9 changes: 7 additions & 2 deletions apps/webapp/app/components/primitives/charts/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ const ChartTooltipContent = React.forwardRef<
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
}
>(
(
Expand All @@ -121,6 +123,7 @@ const ChartTooltipContent = React.forwardRef<
color,
nameKey,
labelKey,
valueFormatter,
},
ref
) => {
Expand Down Expand Up @@ -221,9 +224,11 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
{valueFormatter && typeof item.value === "number"
? valueFormatter(item.value)
: item.value.toLocaleString()}
</span>
)}
</div>
Expand Down
5 changes: 4 additions & 1 deletion apps/webapp/app/components/primitives/charts/ChartBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type ChartBarRendererProps = {
referenceLine?: ReferenceLineProps;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
Expand All @@ -72,6 +74,7 @@ export function ChartBarRenderer({
yAxisProps: yAxisPropsProp,
referenceLine,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartBarRendererProps) {
Expand Down Expand Up @@ -170,7 +173,7 @@ export function ChartBarRenderer({
showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent />
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
Expand Down
Loading