diff --git a/apps/backend/src/agents/tools/story.ts b/apps/backend/src/agents/tools/story.ts index c9d2ce39b..bb531d0e4 100644 --- a/apps/backend/src/agents/tools/story.ts +++ b/apps/backend/src/agents/tools/story.ts @@ -15,6 +15,7 @@ export default createTool({ 'Use "create" to initialize a new story, "update" to search-and-replace within it (producing a new version),', 'or "replace" to overwrite the entire content (producing a new version).', 'Charts are embedded via .', + 'For kpi_card charts you may add comparison_mode="percentage|variation|absolute" to show a period-over-period change pill; this requires the query to return at least two time-ordered rows (one per period) for the metric, and kpi_card does not need x_axis_key.', 'SQL result tables are embedded via .', 'Use ... to place 2–4 charts/tables side by side; its direct /
blocks are the columns.', 'For unequal columns add widths="w1,w2,..." to the — one positive integer per column giving its relative width (e.g. widths="2,1" makes the first column twice as wide as the second). The number of values must equal the number of columns; omit widths for equal columns. Choose widths that fit the content, e.g. a wide time-series next to a narrow KPI or pie.', diff --git a/apps/backend/src/components/generate-chart.tsx b/apps/backend/src/components/generate-chart.tsx index 5155a3ea3..fafb15d99 100644 --- a/apps/backend/src/components/generate-chart.tsx +++ b/apps/backend/src/components/generate-chart.tsx @@ -14,7 +14,7 @@ import { export interface RenderChartInput { config: Pick< - displayChart.ChartInput, + displayChart.KpiCardInput, | 'chart_type' | 'x_axis_key' | 'x_axis_type' @@ -27,6 +27,7 @@ export interface RenderChartInput { | 'y_axis_right_label' | 'title' | 'show_data_labels' + | 'comparison_mode' >; data: Record[]; width?: number; @@ -47,6 +48,7 @@ export function renderChartToSvg(input: RenderChartInput): string { const height = input.height ?? 500; const margin = input.margin ?? { top: 10, right: 20, bottom: 5, left: 0 }; const includeLegend = input.includeLegend !== false; + const xAxisKey = config.x_axis_key ?? ''; const colorFor = (key: string, index: number) => { const series = config.series.find((s) => s.data_key === key); @@ -54,16 +56,16 @@ export function renderChartToSvg(input: RenderChartInput): string { }; const labelFormatter = (value: string) => labelize(value, dateFormat); - const maxLabelWidth = estimateMaxLabelWidth(data, config.x_axis_key, dateFormat); + const maxLabelWidth = estimateMaxLabelWidth(data, xAxisKey, dateFormat); const isPie = config.chart_type === 'pie' || config.chart_type === 'donut'; - const chartData = isPie ? bucketPieData(data, config.x_axis_key, config.series[0]?.data_key ?? '') : data; + const chartData = isPie ? bucketPieData(data, xAxisKey, config.series[0]?.data_key ?? '') : data; let legend: LegendEntry[] = []; if (includeLegend) { legend = isPie - ? buildPieLegendEntries(chartData, config.x_axis_key, dateFormat) + ? buildPieLegendEntries(chartData, xAxisKey, dateFormat) : config.series.map((s, i) => ({ label: s.label || labelize(s.data_key, dateFormat), color: colorFor(s.data_key, i), @@ -78,7 +80,7 @@ export function renderChartToSvg(input: RenderChartInput): string { const chart = buildChart({ data: chartData, chartType: config.chart_type, - xAxisKey: config.x_axis_key, + xAxisKey, xAxisType: config.x_axis_type === 'number' ? 'number' : 'category', series: config.series, colorFor, @@ -91,6 +93,7 @@ export function renderChartToSvg(input: RenderChartInput): string { backgroundColor: '#ffffff', yAxisMin: config.y_axis_min, yAxisMax: config.y_axis_max, + comparisonMode: config.comparison_mode, yAxisLabel: config.y_axis_label, yAxisRightMin: config.y_axis_right_min, yAxisRightMax: config.y_axis_right_max, diff --git a/apps/backend/src/services/automation-tools.ts b/apps/backend/src/services/automation-tools.ts index 737f573a4..0b5205750 100644 --- a/apps/backend/src/services/automation-tools.ts +++ b/apps/backend/src/services/automation-tools.ts @@ -358,7 +358,9 @@ function appendInlineChartImages(html: string, attachments: GeneratedArtifactAtt return `${html}${chartSection}`; } -function uniqueCharts(charts: displayChart.ChartInput[]): displayChart.ChartInput[] { +function uniqueCharts( + charts: (displayChart.ChartInput | displayChart.KpiCardInput)[], +): (displayChart.ChartInput | displayChart.KpiCardInput)[] { const seen = new Set(); return charts.filter((chart) => { const key = JSON.stringify(chart); diff --git a/apps/backend/src/trpc/chart.routes.ts b/apps/backend/src/trpc/chart.routes.ts index 00cb373f2..59c52f37f 100644 --- a/apps/backend/src/trpc/chart.routes.ts +++ b/apps/backend/src/trpc/chart.routes.ts @@ -75,7 +75,9 @@ export const chartRoutes = { }), }; -async function readDownloadableChartConfig(toolCallId: string): Promise { +async function readDownloadableChartConfig( + toolCallId: string, +): Promise { const config = await getDisplayConfigByToolCallId(toolCallId); if (config.chart_type === 'table') { throw new TRPCError({ diff --git a/apps/backend/src/types/tools.ts b/apps/backend/src/types/tools.ts index 6fa50a1e6..b382aa384 100644 --- a/apps/backend/src/types/tools.ts +++ b/apps/backend/src/types/tools.ts @@ -8,7 +8,7 @@ export interface QueryResult { } export interface GeneratedArtifacts { - charts: displayChart.ChartInput[]; + charts: (displayChart.ChartInput | displayChart.KpiCardInput)[]; stories: { id: string; title: string }[]; } diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 93893736d..0082eed17 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -1,4 +1,11 @@ -import { bucketPieData, DEFAULT_COLORS, defaultColorFor, formatCompactNumber, labelize } from '@nao/shared'; +import { + bucketPieData, + computeKpiComparison, + DEFAULT_COLORS, + defaultColorFor, + formatCompactNumber, + labelize, +} from '@nao/shared'; import { type DateFormatSettings, DEFAULT_DATE_FORMAT_SETTINGS, @@ -205,7 +212,15 @@ function ChartLegend({ series }: { series: ParsedChartBlock['series'] }) { } function KpiCards({ chart, rows }: { chart: ParsedChartBlock; rows: Record[] }) { - const firstRow = rows[0] ?? {}; + const sortedRows = [...rows].sort((a, b) => { + const av = a[chart.xAxisKey]; + const bv = b[chart.xAxisKey]; + if (chart.xAxisType === 'date') { + return new Date(String(av)).getTime() - new Date(String(bv)).getTime(); + } + return 0; + }); + const lastRow = sortedRows[sortedRows.length - 1] ?? {}; return (
{chart.series.map((s) => { - const raw = firstRow[s.data_key]; + const raw = lastRow[s.data_key]; const value = typeof raw === 'number' ? formatCompactNumber(raw) : String(raw ?? ''); const label = s.label ?? s.data_key; + const comparison = computeKpiComparison(sortedRows, chart.xAxisKey, s.data_key, chart.comparisonMode); return (
{label}
-
{value}
+
+ {value} +
+ {comparison && + (() => { + const showArrow = comparison.colored && comparison.direction !== 'flat'; + const color = showArrow + ? comparison.direction === 'up' + ? '#16a34a' + : '#dc2626' + : '#6b7280'; + return ( +
+ {showArrow && ( + + + + )} + + {comparison.valueText} + + vs. {comparison.periodLabel} +
+ ); + })()}
); })} diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index 4c58adcfb..17d241f8a 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -70,7 +70,12 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ const xAxisType = chart.xAxisType === 'number' ? 'number' : ('category' as const); return ( - + @@ -95,6 +101,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ interface StoryChartEmbedShellProps { chart: ChartBlock; availableColumns: string[]; + data?: Record[]; dragHandle?: React.ReactNode; children: React.ReactNode; } @@ -103,12 +110,18 @@ interface StoryChartEmbedShellProps { * Wraps a rendered chart with an "Edit chart" button when the surrounding story * context provides a save handler. */ -export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, children }: StoryChartEmbedShellProps) { +export function StoryChartEmbedShell({ + chart, + availableColumns, + data, + dragHandle, + children, +}: StoryChartEmbedShellProps) { const edit = useStoryChartEdit(); const [isEditOpen, setIsEditOpen] = useState(false); const canEdit = Boolean(edit && chart.rawTag); - const config = useMemo( + const config = useMemo( () => ({ query_id: chart.queryId, chart_type: chart.chartType as displayChart.ChartType, @@ -130,16 +143,30 @@ export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, chil y_axis_right_label: chart.yAxisRightLabel, title: chart.title, show_data_labels: chart.showDataLabels, + comparison_mode: chart.comparisonMode, hide_total: chart.hideTotal, }), [chart], ); + const isKpi = chart.chartType === 'kpi_card'; + const editButton = canEdit ? ( + + ) : null; + return (
- {(canEdit || dragHandle != null || (chart.chartType != 'kpi_card' && chart.title)) && ( + {!isKpi && (canEdit || dragHandle != null || chart.title) && (
- {chart.chartType != 'kpi_card' && chart.title ? ( + {chart.title ? ( {chart.title} @@ -148,22 +175,18 @@ export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, chil )}
{dragHandle} - {canEdit && ( - - )} + {editButton}
)} -
+
{children} + {isKpi && (dragHandle != null || editButton) && ( +
+ {dragHandle} + {editButton} +
+ )}
{canEdit && edit && chart.rawTag && ( edit.saveChart(chart.rawTag!, next)} description={edit.saveDescription} diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index 084eaa317..6a3ec97c3 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -75,7 +75,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ } return ( - + diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx index fca0d8922..ad1884862 100644 --- a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_COLORS } from '@nao/shared'; +import { computeKpiComparison, DEFAULT_COLORS } from '@nao/shared'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ChartArea, ChartBar, ChartColumn, ChartColumnIncreasing, ChartLine, Plus, Trash2 } from 'lucide-react'; @@ -36,6 +36,13 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' { value: 'number', label: 'Number' }, ]; +const COMPARISON_MODE_OPTIONS: { value: displayChart.ComparisonMode; label: string }[] = [ + { value: 'none', label: 'None' }, + { value: 'percentage', label: 'Percentage' }, + { value: 'variation', label: 'Variation' }, + { value: 'absolute', label: 'Absolute' }, +]; + const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string; icon: LucideIcon }[] = [ { value: 'bar', label: 'Bar', icon: ChartColumnIncreasing }, { value: 'line', label: 'Line', icon: ChartLine }, @@ -44,6 +51,8 @@ const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string; icon const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); +type EditableChartInput = displayChart.ChartInput | displayChart.KpiCardInput; + /** Maps a 100% stacked type back to its absolute-stacked counterpart, so the type dropdown stays clean. */ function baseChartType(type: displayChart.ChartType): displayChart.ChartType { if (type === 'stacked_bar_100') { @@ -69,11 +78,12 @@ function percentChartType(type: displayChart.ChartType): displayChart.ChartType interface ChartConfigEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - config: displayChart.ChartInput; + config: EditableChartInput; availableColumns: string[]; - onSave: (next: displayChart.ChartInput) => Promise; + onSave: (next: EditableChartInput) => Promise; isSaving?: boolean; description?: string; + data?: Record[]; } /** Presentational edit dialog for `display_chart` configuration. */ @@ -85,8 +95,9 @@ export function ChartConfigEditDialog({ onSave, isSaving = false, description = 'Tweak the chart parameters.', + data, }: ChartConfigEditDialogProps) { - const [draft, setDraft] = useState(config); + const [draft, setDraft] = useState(config); const [yAxisMinText, setYAxisMinText] = useState(toRangeString(config.y_axis_min)); const [yAxisMaxText, setYAxisMaxText] = useState(toRangeString(config.y_axis_max)); const [yAxisRightMinText, setYAxisRightMinText] = useState(toRangeString(config.y_axis_right_min)); @@ -96,6 +107,10 @@ export function ChartConfigEditDialog({ // the same swatch the chart draws for it. Refreshed on open for the theme. const [paletteHexes, setPaletteHexes] = useState(DEFAULT_COLORS); const supportsYAxisRange = !Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES.has(draft.chart_type); + const canShowComparisonPill = useMemo( + () => draft.chart_type === 'kpi_card' && hasRenderableKpiComparison(data, draft.x_axis_key, draft.series), + [draft.chart_type, draft.x_axis_key, draft.series, data], + ); const isCombo = displayChart.chartTypeSupportsComboSeries(draft.chart_type); const hasRightAxis = isCombo && displayChart.hasRightAxisSeries(draft.series); const hasLeftAxis = !isCombo || draft.series.some((s) => s.y_axis !== 'right'); @@ -114,18 +129,26 @@ export function ChartConfigEditDialog({ const xAxisOptions = useMemo(() => { if (availableColumns.length === 0) { - return [config.x_axis_key]; + return [config.x_axis_key ?? '']; } return availableColumns; }, [availableColumns, config.x_axis_key]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const parsed = displayChart.ChartInputSchema.safeParse(draft); + const normalized: EditableChartInput = + draft.chart_type === 'kpi_card' + ? { ...draft, x_axis_key: draft.x_axis_key || '', x_axis_type: draft.x_axis_type ?? null } + : draft; + const parsed = displayChart.InputSchema.safeParse(normalized); if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? 'Invalid chart configuration.'); return; } + if (parsed.data.chart_type === 'table') { + setError('Invalid chart configuration.'); + return; + } try { await onSave(parsed.data); @@ -244,29 +267,31 @@ export function ChartConfigEditDialog({
-
- X-axis type - -
+ {draft.chart_type !== 'kpi_card' && ( +
+ X-axis type + +
+ )}
{displayChart.isStackedChartType(draft.chart_type) && ( @@ -294,14 +319,16 @@ export function ChartConfigEditDialog({
)} -
- X-axis column - setDraft((prev) => ({ ...prev, x_axis_key: value }))} - /> -
+ {draft.chart_type !== 'kpi_card' && ( +
+ X-axis column + setDraft((prev) => ({ ...prev, x_axis_key: value }))} + /> +
+ )}
@@ -450,6 +477,31 @@ export function ChartConfigEditDialog({
Options + {canShowComparisonPill && ( +
+ Comparison pill + +
+ )}