Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/backend/src/agents/tools/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default createTool<story.Input, story.Output>({
'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 <chart query_id="..." chart_type="..." x_axis_key="..." series=\'[...]\' title="..." />.',
'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 <table query_id="..." title="..." />.',
'Use <grid>...</grid> to place 2–4 charts/tables side by side; its direct <chart>/<table> blocks are the columns.',
'For unequal columns add widths="w1,w2,..." to the <grid> — 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.',
Expand Down
13 changes: 8 additions & 5 deletions apps/backend/src/components/generate-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {

export interface RenderChartInput {
config: Pick<
displayChart.ChartInput,
displayChart.KpiCardInput,
| 'chart_type'
| 'x_axis_key'
| 'x_axis_type'
Expand All @@ -27,6 +27,7 @@ export interface RenderChartInput {
| 'y_axis_right_label'
| 'title'
| 'show_data_labels'
| 'comparison_mode'
>;
data: Record<string, unknown>[];
width?: number;
Expand All @@ -47,23 +48,24 @@ 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);
return series?.color || defaultColorFor(key, index);
};

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),
Expand All @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/services/automation-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
return charts.filter((chart) => {
const key = JSON.stringify(chart);
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/trpc/chart.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ export const chartRoutes = {
}),
};

async function readDownloadableChartConfig(toolCallId: string): Promise<displayChart.ChartInput> {
async function readDownloadableChartConfig(
toolCallId: string,
): Promise<displayChart.ChartInput | displayChart.KpiCardInput> {
const config = await getDisplayConfigByToolCallId(toolCallId);
if (config.chart_type === 'table') {
throw new TRPCError({
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface QueryResult {
}

export interface GeneratedArtifacts {
charts: displayChart.ChartInput[];
charts: (displayChart.ChartInput | displayChart.KpiCardInput)[];
stories: { id: string; title: string }[];
}

Expand Down
80 changes: 76 additions & 4 deletions apps/backend/src/utils/story-html.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -205,7 +212,15 @@ function ChartLegend({ series }: { series: ParsedChartBlock['series'] }) {
}

function KpiCards({ chart, rows }: { chart: ParsedChartBlock; rows: Record<string, unknown>[] }) {
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 (
<div
style={{
Expand All @@ -218,13 +233,70 @@ function KpiCards({ chart, rows }: { chart: ParsedChartBlock; rows: Record<strin
}}
>
{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 (
<div key={s.data_key} style={{ minWidth: 160 }}>
<div style={{ fontSize: 18, letterSpacing: '0.025em', color: '#1f2937' }}>{label}</div>
<div style={{ fontSize: 30, fontWeight: 500, color: '#111827' }}>{value}</div>
<div
style={{
fontSize: 30,
fontWeight: 500,
color: '#111827',
fontVariantNumeric: 'tabular-nums',
}}
>
{value}
</div>
{comparison &&
(() => {
const showArrow = comparison.colored && comparison.direction !== 'flat';
const color = showArrow
? comparison.direction === 'up'
? '#16a34a'
: '#dc2626'
: '#6b7280';
return (
<div
style={{
marginTop: 6,
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
color,
whiteSpace: 'nowrap',
}}
>
{showArrow && (
<svg
width='10'
height='10'
viewBox='0 0 14 12'
fill='currentColor'
stroke='currentColor'
strokeWidth='1.6'
strokeLinejoin='round'
style={{ flexShrink: 0 }}
>
<path
d={
comparison.direction === 'up'
? 'M7 2.5 12 10 2 10Z'
: 'M2 2.5 12 2.5 7 10Z'
}
/>
</svg>
)}
<span style={{ fontWeight: 500, fontVariantNumeric: 'tabular-nums' }}>
{comparison.valueText}
</span>
<span style={{ fontWeight: 400 }}>vs. {comparison.periodLabel}</span>
</div>
);
})()}
</div>
);
})}
Expand Down
58 changes: 41 additions & 17 deletions apps/frontend/src/components/side-panel/story-chart-embed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
const xAxisType = chart.xAxisType === 'number' ? 'number' : ('category' as const);

return (
<StoryChartEmbedShell chart={chart} availableColumns={sourceData.columns ?? []} dragHandle={dragHandle}>
<StoryChartEmbedShell
chart={chart}
availableColumns={sourceData.columns ?? []}
data={sourceData.data ?? []}
dragHandle={dragHandle}
>
<ChartDisplay
data={data}
chartType={chart.chartType as displayChart.ChartType}
Expand All @@ -85,6 +90,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
yAxisRightMax={chart.yAxisRightMax}
yAxisRightLabel={chart.yAxisRightLabel}
showDataLabels={chart.showDataLabels}
comparisonMode={chart.comparisonMode}
normalSize
hideTotal={chart.hideTotal}
/>
Expand All @@ -95,6 +101,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
interface StoryChartEmbedShellProps {
chart: ChartBlock;
availableColumns: string[];
data?: Record<string, unknown>[];
dragHandle?: React.ReactNode;
children: React.ReactNode;
}
Expand All @@ -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<displayChart.ChartInput>(
const config = useMemo<displayChart.KpiCardInput>(
() => ({
query_id: chart.queryId,
chart_type: chart.chartType as displayChart.ChartType,
Expand All @@ -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 ? (
<Button
variant='ghost-muted'
size='icon-xs'
onClick={() => setIsEditOpen(true)}
title='Edit chart'
className='shrink-0 hover:bg-accent hover:rounded-full'
>
<Pencil className='size-3.5' />
</Button>
) : null;

return (
<div className='my-2 flex flex-col gap-4'>
{(canEdit || dragHandle != null || (chart.chartType != 'kpi_card' && chart.title)) && (
{!isKpi && (canEdit || dragHandle != null || chart.title) && (
<div className='flex w-full items-center justify-between gap-2'>
{chart.chartType != 'kpi_card' && chart.title ? (
{chart.title ? (
<span className='text-sm font-medium text-foreground flex-1 min-w-0 truncate'>
{chart.title}
</span>
Expand All @@ -148,29 +175,26 @@ export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, chil
)}
<div className='flex shrink-0 items-center gap-1'>
{dragHandle}
{canEdit && (
<Button
variant='ghost-muted'
size='icon-xs'
onClick={() => setIsEditOpen(true)}
title='Edit chart'
className='shrink-0 hover:bg-accent hover:rounded-full'
>
<Pencil className='size-3.5' />
</Button>
)}
{editButton}
</div>
</div>
)}
<div className={`relative ${chart.chartType != 'kpi_card' ? STORY_CHART_HEIGHT_CLASS : ''}`}>
<div className={`relative ${!isKpi ? STORY_CHART_HEIGHT_CLASS : ''}`}>
{children}
{isKpi && (dragHandle != null || editButton) && (
<div className='absolute top-0 right-0 flex items-center gap-1'>
{dragHandle}
{editButton}
</div>
)}
</div>
{canEdit && edit && chart.rawTag && (
<ChartConfigEditDialog
open={isEditOpen}
onOpenChange={setIsEditOpen}
config={config}
availableColumns={availableColumns}
data={data}
isSaving={edit.isSaving}
onSave={(next) => edit.saveChart(chart.rawTag!, next)}
description={edit.saveDescription}
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/components/story-embeds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
}

return (
<StoryChartEmbedShell chart={chart} availableColumns={resolvedColumns}>
<StoryChartEmbedShell chart={chart} availableColumns={resolvedColumns} data={resolvedData}>
<ChartDisplay
data={resolvedData}
chartType={chart.chartType as displayChart.ChartType}
Expand All @@ -90,6 +90,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
yAxisRightMax={chart.yAxisRightMax}
yAxisRightLabel={chart.yAxisRightLabel}
showDataLabels={chart.showDataLabels}
comparisonMode={chart.comparisonMode}
normalSize
hideTotal={chart.hideTotal}
/>
Expand Down
Loading
Loading