Skip to content

Commit 3332b37

Browse files
committed
Graphing of dates on x-axis fills in the blanks
1 parent ecb2e27 commit 3332b37

File tree

2 files changed

+226
-51
lines changed

2 files changed

+226
-51
lines changed

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

Lines changed: 221 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ interface TransformedData {
5151
series: string[];
5252
/** Raw date values for determining formatting granularity */
5353
dateValues: Date[];
54+
/** Whether the x-axis is date-based (continuous time scale) */
55+
isDateBased: boolean;
56+
/** The data key to use for x-axis (column name or '__timestamp' for dates) */
57+
xDataKey: string;
58+
/** Min/max timestamps for domain when date-based */
59+
timeDomain: [number, number] | null;
60+
/** Pre-calculated tick values for the time axis */
61+
timeTicks: number[] | null;
5462
}
5563

5664
/**
@@ -134,6 +142,97 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
134142
}
135143
}
136144

145+
/**
146+
* "Nice" intervals for time axes - these create human-friendly tick marks
147+
*/
148+
const NICE_TIME_INTERVALS = [
149+
{ value: 1000, label: "1s" }, // 1 second
150+
{ value: 5 * 1000, label: "5s" }, // 5 seconds
151+
{ value: 10 * 1000, label: "10s" }, // 10 seconds
152+
{ value: 30 * 1000, label: "30s" }, // 30 seconds
153+
{ value: 60 * 1000, label: "1m" }, // 1 minute
154+
{ value: 5 * 60 * 1000, label: "5m" }, // 5 minutes
155+
{ value: 10 * 60 * 1000, label: "10m" }, // 10 minutes
156+
{ value: 15 * 60 * 1000, label: "15m" }, // 15 minutes
157+
{ value: 30 * 60 * 1000, label: "30m" }, // 30 minutes
158+
{ value: 60 * 60 * 1000, label: "1h" }, // 1 hour
159+
{ value: 2 * 60 * 60 * 1000, label: "2h" }, // 2 hours
160+
{ value: 3 * 60 * 60 * 1000, label: "3h" }, // 3 hours
161+
{ value: 4 * 60 * 60 * 1000, label: "4h" }, // 4 hours
162+
{ value: 6 * 60 * 60 * 1000, label: "6h" }, // 6 hours
163+
{ value: 12 * 60 * 60 * 1000, label: "12h" }, // 12 hours
164+
{ value: 24 * 60 * 60 * 1000, label: "1d" }, // 1 day
165+
{ value: 2 * 24 * 60 * 60 * 1000, label: "2d" }, // 2 days
166+
{ value: 7 * 24 * 60 * 60 * 1000, label: "1w" }, // 1 week
167+
{ value: 14 * 24 * 60 * 60 * 1000, label: "2w" }, // 2 weeks
168+
{ value: 30 * 24 * 60 * 60 * 1000, label: "1mo" }, // ~1 month
169+
{ value: 90 * 24 * 60 * 60 * 1000, label: "3mo" }, // ~3 months
170+
{ value: 180 * 24 * 60 * 60 * 1000, label: "6mo" }, // ~6 months
171+
{ value: 365 * 24 * 60 * 60 * 1000, label: "1y" }, // 1 year
172+
];
173+
174+
/**
175+
* Generate evenly-spaced tick values for a time axis using "nice" intervals
176+
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
177+
*/
178+
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
179+
const range = maxTime - minTime;
180+
181+
if (range <= 0) {
182+
return [minTime];
183+
}
184+
185+
// Find the best "nice" interval that gives us a reasonable number of ticks
186+
// Target: between 4 and maxTicks ticks
187+
let chosenInterval = NICE_TIME_INTERVALS[NICE_TIME_INTERVALS.length - 1].value;
188+
189+
for (const { value: interval } of NICE_TIME_INTERVALS) {
190+
const tickCount = Math.ceil(range / interval);
191+
if (tickCount <= maxTicks && tickCount >= 2) {
192+
chosenInterval = interval;
193+
break;
194+
}
195+
}
196+
197+
// Align the start tick to a nice boundary
198+
// For intervals >= 1 day, align to midnight
199+
// For intervals >= 1 hour, align to hour boundary
200+
// For intervals >= 1 minute, align to minute boundary
201+
const DAY = 24 * 60 * 60 * 1000;
202+
const HOUR = 60 * 60 * 1000;
203+
const MINUTE = 60 * 1000;
204+
205+
let alignTo: number;
206+
if (chosenInterval >= DAY) {
207+
// Align to midnight UTC (or we could use local midnight)
208+
alignTo = DAY;
209+
} else if (chosenInterval >= HOUR) {
210+
alignTo = chosenInterval; // Align to the interval itself for hours
211+
} else if (chosenInterval >= MINUTE) {
212+
alignTo = chosenInterval;
213+
} else {
214+
alignTo = chosenInterval;
215+
}
216+
217+
// Round down to the alignment boundary, then find first tick at or before minTime
218+
const startTick = Math.floor(minTime / alignTo) * alignTo;
219+
220+
// Generate ticks
221+
const ticks: number[] = [];
222+
for (let t = startTick; t <= maxTime + chosenInterval; t += chosenInterval) {
223+
if (t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1) {
224+
ticks.push(t);
225+
}
226+
}
227+
228+
// Ensure we have at least 2 ticks
229+
if (ticks.length < 2) {
230+
return [minTime, maxTime];
231+
}
232+
233+
return ticks;
234+
}
235+
137236
/**
138237
* Formats a date for tooltips (always shows full precision)
139238
*/
@@ -201,6 +300,10 @@ function tryParseDate(value: unknown): Date | null {
201300
*
202301
* When not grouped:
203302
* - Uses Y-axis columns directly as series
303+
*
304+
* For date-based x-axes:
305+
* - Uses numeric timestamps so the chart renders with a continuous time scale
306+
* - This ensures gaps in data are visually apparent
204307
*/
205308
function transformDataForChart(
206309
rows: Record<string, unknown>[],
@@ -209,7 +312,15 @@ function transformDataForChart(
209312
const { xAxisColumn, yAxisColumns, groupByColumn } = config;
210313

211314
if (!xAxisColumn || yAxisColumns.length === 0) {
212-
return { data: [], series: [], dateValues: [] };
315+
return {
316+
data: [],
317+
series: [],
318+
dateValues: [],
319+
isDateBased: false,
320+
xDataKey: xAxisColumn || "",
321+
timeDomain: null,
322+
timeTicks: null,
323+
};
213324
}
214325

215326
// Collect date values for granularity detection
@@ -221,75 +332,104 @@ function transformDataForChart(
221332
}
222333
}
223334

224-
// Determine if X-axis is date-based and detect granularity
225-
const isDateBased = dateValues.length > 0;
335+
// Determine if X-axis is date-based (most values should be parseable as dates)
336+
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
226337
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
227338

228-
// Helper to format X value (keeps raw value for non-dates, formats dates)
339+
// For date-based axes, use a special key for the timestamp
340+
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
341+
342+
// Calculate time domain and ticks for date-based axes
343+
let timeDomain: [number, number] | null = null;
344+
let timeTicks: number[] | null = null;
345+
if (isDateBased && dateValues.length > 0) {
346+
const timestamps = dateValues.map((d) => d.getTime());
347+
const minTime = Math.min(...timestamps);
348+
const maxTime = Math.max(...timestamps);
349+
// Add a small padding (2% on each side) so points aren't at the very edge
350+
const padding = (maxTime - minTime) * 0.02;
351+
timeDomain = [minTime - padding, maxTime + padding];
352+
// Generate evenly-spaced ticks across the entire range using nice intervals
353+
timeTicks = generateTimeTicks(minTime, maxTime);
354+
}
355+
356+
// Helper to format X value for categorical axes (non-date)
229357
const formatX = (value: unknown): string => {
230358
if (value === null || value === undefined) return "N/A";
231-
const date = tryParseDate(value);
232-
if (date) {
233-
return formatDateByGranularity(date, granularity);
234-
}
235359
return String(value);
236360
};
237361

238362
// No grouping: use Y columns directly as series
239363
if (!groupByColumn) {
240-
const data = rows.map((row) => {
241-
const point: Record<string, unknown> = {
242-
[xAxisColumn]: formatX(row[xAxisColumn]),
243-
// Store raw date for tooltip
244-
__rawDate: tryParseDate(row[xAxisColumn]),
245-
__granularity: granularity,
246-
};
247-
for (const yCol of yAxisColumns) {
248-
point[yCol] = toNumber(row[yCol]);
249-
}
250-
return point;
251-
});
252-
253-
return { data, series: yAxisColumns, dateValues };
364+
const data = rows
365+
.map((row) => {
366+
const rawDate = tryParseDate(row[xAxisColumn]);
367+
const point: Record<string, unknown> = {
368+
// For date-based, use timestamp; otherwise use formatted string
369+
[xDataKey]: isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]),
370+
// Store raw date and original value for tooltip
371+
__rawDate: rawDate,
372+
__granularity: granularity,
373+
__originalX: row[xAxisColumn],
374+
};
375+
for (const yCol of yAxisColumns) {
376+
point[yCol] = toNumber(row[yCol]);
377+
}
378+
return point;
379+
})
380+
// Filter out rows with invalid dates for date-based axes
381+
.filter((point) => !isDateBased || point.__rawDate !== null);
382+
383+
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
254384
}
255385

256386
// With grouping: pivot data so each group value becomes a series
257387
const yCol = yAxisColumns[0]; // Use first Y column when grouping
258388
const groupValues = new Set<string>();
259-
const groupedByX = new Map<string, { values: Record<string, number>; rawDate: Date | null }>();
389+
390+
// For date-based, key by timestamp; otherwise by formatted string
391+
const groupedByX = new Map<
392+
string | number,
393+
{ values: Record<string, number>; rawDate: Date | null; originalX: unknown }
394+
>();
260395

261396
for (const row of rows) {
262-
const xValue = formatX(row[xAxisColumn]);
263397
const rawDate = tryParseDate(row[xAxisColumn]);
398+
399+
// Skip rows with invalid dates for date-based axes
400+
if (isDateBased && !rawDate) continue;
401+
402+
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
264403
const groupValue = String(row[groupByColumn] ?? "Unknown");
265404
const yValue = toNumber(row[yCol]);
266405

267406
groupValues.add(groupValue);
268407

269-
if (!groupedByX.has(xValue)) {
270-
groupedByX.set(xValue, { values: {}, rawDate });
408+
if (!groupedByX.has(xKey)) {
409+
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
271410
}
272411

273-
const existing = groupedByX.get(xValue)!;
412+
const existing = groupedByX.get(xKey)!;
274413
// Sum values if there are multiple rows with same x + group
275414
existing.values[groupValue] = (existing.values[groupValue] ?? 0) + yValue;
276415
}
277416

278417
// Convert to array format
279418
const series = Array.from(groupValues).sort();
280-
const data = Array.from(groupedByX.entries()).map(([xValue, { values, rawDate }]) => {
419+
const data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
281420
const point: Record<string, unknown> = {
282-
[xAxisColumn]: xValue,
421+
[xDataKey]: xKey,
283422
__rawDate: rawDate,
284423
__granularity: granularity,
424+
__originalX: originalX,
285425
};
286426
for (const group of series) {
287427
point[group] = values[group] ?? 0;
288428
}
289429
return point;
290430
});
291431

292-
return { data, series, dateValues };
432+
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
293433
}
294434

295435
function toNumber(value: unknown): number {
@@ -363,20 +503,36 @@ export const QueryResultsChart = memo(function QueryResultsChart({
363503
data: unsortedData,
364504
series,
365505
dateValues,
506+
isDateBased,
507+
xDataKey,
508+
timeDomain,
509+
timeTicks,
366510
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
367511

368-
// Apply sorting
369-
const data = useMemo(
370-
() => sortData(unsortedData, sortByColumn, sortDirection),
371-
[unsortedData, sortByColumn, sortDirection]
372-
);
512+
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
513+
const data = useMemo(() => {
514+
if (isDateBased) {
515+
// Always sort by timestamp for date-based axes
516+
return sortData(unsortedData, xDataKey, "asc");
517+
}
518+
return sortData(unsortedData, sortByColumn, sortDirection);
519+
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
373520

374521
// Detect time granularity for the data
375522
const timeGranularity = useMemo(
376523
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
377524
[dateValues]
378525
);
379526

527+
// X-axis tick formatter for date-based axes
528+
const xAxisTickFormatter = useMemo(() => {
529+
if (!isDateBased || !timeGranularity) return undefined;
530+
return (value: number) => {
531+
const date = new Date(value);
532+
return formatDateByGranularity(date, timeGranularity);
533+
};
534+
}, [isDateBased, timeGranularity]);
535+
380536
// Create dynamic Y-axis formatter based on data range
381537
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
382538

@@ -432,17 +588,36 @@ export const QueryResultsChart = memo(function QueryResultsChart({
432588
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
433589
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
434590

435-
const xAxisProps = {
436-
dataKey: xAxisColumn,
437-
fontSize: 12,
438-
tickLine: false,
439-
tickMargin: 8,
440-
axisLine: false,
441-
tick: { fill: "var(--color-text-dimmed)" },
442-
angle: xAxisAngle,
443-
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
444-
height: xAxisHeight,
445-
};
591+
// Build xAxisProps - different config for date-based (continuous) vs categorical axes
592+
const xAxisProps = isDateBased
593+
? {
594+
dataKey: xDataKey,
595+
type: "number" as const,
596+
domain: timeDomain ?? ["auto", "auto"],
597+
scale: "time" as const,
598+
// Explicitly specify tick positions so labels appear across the entire range
599+
ticks: timeTicks ?? undefined,
600+
fontSize: 12,
601+
tickLine: false,
602+
tickMargin: 8,
603+
axisLine: false,
604+
tick: { fill: "var(--color-text-dimmed)" },
605+
tickFormatter: xAxisTickFormatter,
606+
angle: xAxisAngle,
607+
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
608+
height: xAxisHeight,
609+
}
610+
: {
611+
dataKey: xDataKey,
612+
fontSize: 12,
613+
tickLine: false,
614+
tickMargin: 8,
615+
axisLine: false,
616+
tick: { fill: "var(--color-text-dimmed)" },
617+
angle: xAxisAngle,
618+
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
619+
height: xAxisHeight,
620+
};
446621

447622
const yAxisProps = {
448623
fontSize: 12,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -668,11 +668,11 @@ function AITabContent({
668668
const [autoSubmitPrompt, setAutoSubmitPrompt] = useState<string | undefined>();
669669

670670
const examplePrompts = [
671-
"Show me failed runs from the last 7 days",
672-
"Count of runs by status",
673-
"Top 10 most expensive runs this week",
674-
"Average execution duration by task",
675-
"Runs that crashed or timed out in the last hour",
671+
"Show me failed runs by hour for the past 7 days",
672+
"Count of runs by status by hour for the past 48h",
673+
"Top 50 most expensive runs this week",
674+
"Average execution duration by task this week",
675+
"Run counts by tag in the past 7 days",
676676
];
677677

678678
return (

0 commit comments

Comments
 (0)