From dc72c4926fabc5d2bbc62c1b03e4a791c935f9a1 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:09:38 +0000 Subject: [PATCH 1/5] feat(dashboard): add heatmap display type Accept heatmap as a valid widget display type, route it through the events-stats timeseries query, and render it as a category-by-time grid with intensity shading in the dashboard view. Fixes #1230 --- packages/cli/src/lib/formatters/dashboard.ts | 121 ++++++++++++++++++ packages/cli/src/types/dashboard.ts | 2 + .../cli/test/lib/formatters/dashboard.test.ts | 53 ++++++++ packages/cli/test/types/dashboard.test.ts | 1 + 4 files changed, 177 insertions(+) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647cc..90f58ac42b 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1531,6 +1531,124 @@ function renderPlaceholderContent(message: string): string[] { return [isPlainOutput() ? `(${message})` : muted(`(${message})`)]; } +// --------------------------------------------------------------------------- +// Heatmap renderer +// --------------------------------------------------------------------------- + +/** + * Intensity ramp for heatmap cells. + * + * Index 0 is empty (zero value); 1-4 map increasing intensity to shade + * blocks in plain mode and to a blue→red heat gradient in color mode. + */ +const HEATMAP_SHADES = [" ", "░", "▒", "▓", "█"] as const; +const HEATMAP_COLORS = [ + "#79B8FF", // low — cyan/blue + "#FDB81B", // yellow + "#FF9838", // orange + "#fe4144", // high — red +] as const; + +/** Map a normalized intensity (0-1) to a ramp bucket index (0-4). */ +function heatmapBucket(normalized: number): number { + if (normalized <= 0) { + return 0; + } + return Math.min(4, Math.max(1, Math.ceil(normalized * 4))); +} + +/** Render a single heatmap cell for a normalized intensity. */ +function heatmapCell(normalized: number, plain: boolean): string { + const bucket = heatmapBucket(normalized); + if (plain) { + return HEATMAP_SHADES[bucket] ?? " "; + } + if (bucket === 0) { + return " "; + } + const color = HEATMAP_COLORS[bucket - 1] ?? COLORS.magenta; + return chalk.hex(color)("█"); +} + +/** + * Render heatmap content: one row per series (category), columns over time. + * + * Cell intensity encodes the value relative to the global maximum across all + * cells, so hot spots stand out. Falls back to a "no data" line when there + * are no series or values. + */ +function renderHeatmapContent( + data: TimeseriesResult, + opts: { innerWidth: number; contentHeight: number } +): string[] { + const { innerWidth, contentHeight } = opts; + if (data.series.length === 0) { + return [noDataLine()]; + } + + const plain = isPlainOutput(); + + // Reserve rows for the bottom time axis (2 lines) and a legend (1 line). + const axisLines = 3; + const maxRows = Math.max(1, contentHeight - axisLines); + const series = data.series.slice(0, maxRows); + + const maxLabelLen = Math.min( + 20, + Math.max(4, ...series.map((s) => s.label.length)) + ); + const gutterW = maxLabelLen + 1; // label + space + const chartWidth = Math.max(1, innerWidth - gutterW); + + // Downsample each series to the chart width; find the global max. + const rows = series.map((s) => + downsample( + s.values.map((v) => v.value), + chartWidth + ) + ); + const globalMax = Math.max(1, ...rows.flat()); + + const lines: string[] = []; + for (let i = 0; i < series.length; i += 1) { + const s = series[i]; + const values = rows[i] ?? []; + if (!s) { + continue; + } + const label = + s.label.length > maxLabelLen + ? `${s.label.slice(0, maxLabelLen - 1)}…` + : s.label.padEnd(maxLabelLen); + const cells = values.map((v) => heatmapCell(v / globalMax, plain)).join(""); + const labelStr = plain ? label : chalk.hex(COLORS.cyan)(label); + lines.push(`${labelStr} ${cells}`); + } + + // Bottom time axis, aligned to the chart area. + const timestamps = data.series[0]?.values.map((v) => v.timestamp) ?? []; + if (timestamps.length > 0) { + lines.push( + ...buildTimeAxis({ + timestamps: downsampleTimestamps(timestamps, chartWidth), + chartWidth, + gutterWidth: gutterW, + }) + ); + } + + // Intensity legend: low → high. + const gutter = " ".repeat(gutterW); + if (plain) { + lines.push(`${gutter}low ${HEATMAP_SHADES.slice(1).join("")} high`); + } else { + const ramp = HEATMAP_COLORS.map((c) => chalk.hex(c)("█")).join(""); + lines.push(`${gutter}${muted("low")} ${ramp} ${muted("high")}`); + } + + return lines; +} + /** * Dispatch to the appropriate content renderer based on data type. * @@ -1547,6 +1665,9 @@ function renderContentLines(opts: { switch (data.type) { case "timeseries": + if (widget.displayType === "heatmap") { + return renderHeatmapContent(data, { innerWidth, contentHeight }); + } if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index 8de1b97566..6f937b5c53 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -72,6 +72,7 @@ export const DISPLAY_TYPES = [ "top_n", "details", "categorical_bar", + "heatmap", "wheel", "rage_and_dead_clicks", "server_tree", @@ -1041,6 +1042,7 @@ export const TIMESERIES_DISPLAY_TYPES = new Set([ "stacked_area", "bar", "categorical_bar", + "heatmap", ]); /** Display types that use tabular data (events endpoint) */ diff --git a/packages/cli/test/lib/formatters/dashboard.test.ts b/packages/cli/test/lib/formatters/dashboard.test.ts index dfaed1776c..6492201922 100644 --- a/packages/cli/test/lib/formatters/dashboard.test.ts +++ b/packages/cli/test/lib/formatters/dashboard.test.ts @@ -605,6 +605,59 @@ describe("formatDashboardWithData", () => { expect(output).toContain("(no data)"); }); + test("renders heatmap as one row per series with a legend", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Errors by Browser", + displayType: "heatmap", + layout: { x: 0, y: 0, w: 3, h: 3 }, + data: makeTimeseriesData({ + series: [ + { + label: "Chrome", + values: [ + { timestamp: 1_700_000_000, value: 0 }, + { timestamp: 1_700_000_060, value: 50 }, + { timestamp: 1_700_000_120, value: 100 }, + ], + }, + { + label: "Firefox", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + { timestamp: 1_700_000_120, value: 20 }, + ], + }, + ], + }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("Errors by Browser"); + // Category labels appear as row headers + expect(output).toContain("Chrome"); + expect(output).toContain("Firefox"); + // Intensity legend is present + expect(output).toContain("low"); + expect(output).toContain("high"); + }); + + test("shows no data for empty series in heatmap", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + displayType: "heatmap", + data: makeTimeseriesData({ series: [] }), + }), + ], + }); + const output = formatDashboardWithData(data); + expect(output).toContain("(no data)"); + }); + test("bar chart fills full widget width (no trailing gap)", () => { // Create enough data points that bars should fill the chart area. // With a tall widget (h=3), the renderer uses bar mode (not sparkline). diff --git a/packages/cli/test/types/dashboard.test.ts b/packages/cli/test/types/dashboard.test.ts index cea7174701..aeb7340f3e 100644 --- a/packages/cli/test/types/dashboard.test.ts +++ b/packages/cli/test/types/dashboard.test.ts @@ -93,6 +93,7 @@ describe("DISPLAY_TYPES", () => { "top_n", "details", "categorical_bar", + "heatmap", "wheel", "rage_and_dead_clicks", "server_tree", From ada9a62f43ece223138f27c7311119c94599d5ee Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:23:20 +0000 Subject: [PATCH 2/5] fix(heatmap): pad short series so axis aligns with cells --- packages/cli/src/lib/formatters/dashboard.ts | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 90f58ac42b..cb0770879a 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,13 +1600,14 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Downsample each series to the chart width; find the global max. - const rows = series.map((s) => - downsample( - s.values.map((v) => v.value), - chartWidth - ) - ); + // Downsample each series to the chart width; pad shorter rows so every row + // has exactly `chartWidth` cells (downsample returns early for short input). + const rows = series.map((s) => { + const ds = downsample(s.values.map((v) => v.value), chartWidth); + return ds.length < chartWidth + ? [...ds, ...Array(chartWidth - ds.length).fill(0)] + : ds; + }); const globalMax = Math.max(1, ...rows.flat()); const lines: string[] = []; @@ -1626,11 +1627,16 @@ function renderHeatmapContent( } // Bottom time axis, aligned to the chart area. - const timestamps = data.series[0]?.values.map((v) => v.timestamp) ?? []; - if (timestamps.length > 0) { + const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; + if (firstTs.length > 0) { + const dsTs = downsampleTimestamps(firstTs, chartWidth); + const paddedTs = + dsTs.length < chartWidth + ? [...dsTs, ...Array(chartWidth - dsTs.length).fill(dsTs.at(-1) ?? 0)] + : dsTs; lines.push( ...buildTimeAxis({ - timestamps: downsampleTimestamps(timestamps, chartWidth), + timestamps: paddedTs, chartWidth, gutterWidth: gutterW, }) From 0139f9785f2944ce1eb098f5357485def3674a98 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:26:18 +0000 Subject: [PATCH 3/5] fix(heatmap): use real bucket count for axis alignment --- packages/cli/src/lib/formatters/dashboard.ts | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index cb0770879a..c62d6a779a 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,14 +1600,18 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Downsample each series to the chart width; pad shorter rows so every row - // has exactly `chartWidth` cells (downsample returns early for short input). - const rows = series.map((s) => { - const ds = downsample(s.values.map((v) => v.value), chartWidth); - return ds.length < chartWidth - ? [...ds, ...Array(chartWidth - ds.length).fill(0)] - : ds; - }); + // Determine the actual number of time buckets from the first series + // (downsample returns the original length when shorter than target). + const firstValues = series[0]?.values.map((v) => v.value) ?? []; + const bucketCount = Math.min( + chartWidth, + firstValues.length || chartWidth + ); + + // Downsample every series to that bucket count; no padding. + const rows = series.map((s) => + downsample(s.values.map((v) => v.value), bucketCount) + ); const globalMax = Math.max(1, ...rows.flat()); const lines: string[] = []; @@ -1629,15 +1633,11 @@ function renderHeatmapContent( // Bottom time axis, aligned to the chart area. const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; if (firstTs.length > 0) { - const dsTs = downsampleTimestamps(firstTs, chartWidth); - const paddedTs = - dsTs.length < chartWidth - ? [...dsTs, ...Array(chartWidth - dsTs.length).fill(dsTs.at(-1) ?? 0)] - : dsTs; + const dsTs = downsampleTimestamps(firstTs, bucketCount); lines.push( ...buildTimeAxis({ - timestamps: paddedTs, - chartWidth, + timestamps: dsTs, + chartWidth: bucketCount, gutterWidth: gutterW, }) ); From 9f7f766a91e5cee06d38c1eb2c1ced251c3ef0b6 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:29:09 +0000 Subject: [PATCH 4/5] fix(heatmap): use max series length for bucket count + pad --- packages/cli/src/lib/formatters/dashboard.ts | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index c62d6a779a..6dd2a516a6 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1600,18 +1600,21 @@ function renderHeatmapContent( const gutterW = maxLabelLen + 1; // label + space const chartWidth = Math.max(1, innerWidth - gutterW); - // Determine the actual number of time buckets from the first series - // (downsample returns the original length when shorter than target). - const firstValues = series[0]?.values.map((v) => v.value) ?? []; - const bucketCount = Math.min( - chartWidth, - firstValues.length || chartWidth - ); - - // Downsample every series to that bucket count; no padding. - const rows = series.map((s) => - downsample(s.values.map((v) => v.value), bucketCount) + // Determine the actual number of time buckets from the longest series. + const maxLen = Math.max( + 0, + ...series.map((s) => s.values.length) ); + const bucketCount = Math.min(chartWidth, maxLen || chartWidth); + + // Downsample every series to that bucket count; pad shorter series so every + // row has exactly `bucketCount` cells (downsample returns early for short input). + const rows = series.map((s) => { + const ds = downsample(s.values.map((v) => v.value), bucketCount); + return ds.length < bucketCount + ? [...ds, ...Array(bucketCount - ds.length).fill(0)] + : ds; + }); const globalMax = Math.max(1, ...rows.flat()); const lines: string[] = []; From 9dbf1d10fa7d1b8a5df987175af9737a81cd5687 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sat, 8 Aug 2026 08:33:13 +0000 Subject: [PATCH 5/5] fix(heatmap): source axis timestamps from longest series --- packages/cli/src/lib/formatters/dashboard.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 6dd2a516a6..bbdfa7c707 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1634,9 +1634,13 @@ function renderHeatmapContent( } // Bottom time axis, aligned to the chart area. - const firstTs = data.series[0]?.values.map((v) => v.timestamp) ?? []; - if (firstTs.length > 0) { - const dsTs = downsampleTimestamps(firstTs, bucketCount); + // Find the longest series so its timestamps match the bucketCount. + const longest = series.reduce((a, b) => + (b.values.length > a.values.length ? b : a), series[0] ?? { values: [] } + ); + const axisTs = longest.values.map((v) => v.timestamp); + if (axisTs.length > 0) { + const dsTs = downsampleTimestamps(axisTs, bucketCount); lines.push( ...buildTimeAxis({ timestamps: dsTs,