diff --git a/apps/api/src/agents/tools/base.ts b/apps/api/src/agents/tools/base.ts index 576ae2970..22039d31e 100644 --- a/apps/api/src/agents/tools/base.ts +++ b/apps/api/src/agents/tools/base.ts @@ -258,7 +258,15 @@ export const generateReport = chatTool( ) .default([]) .optional(), - metric: z.enum(['sum', 'count', 'average']).default('sum').optional(), + // No `.default()` here on purpose: it would make an omitted metric + // indistinguishable from an explicit `sum`, and the fallback depends on + // the chart type (see below). + metric: z + .enum(['sum', 'count', 'average', 'min', 'max']) + .optional() + .describe( + 'How a series is aggregated for display. Only the metric and map chart types read this; `count` is unique profiles. Omit it unless the user asked for a specific aggregation — metric cards then default to unique profiles.', + ), previous: z .boolean() .optional() @@ -338,7 +346,11 @@ export const generateReport = chatTool( }), ), range: 'custom' as const, - metric: input.metric ?? 'sum', + // A metric card has always shown the total unique count, so an + // unspecified metric must stay `count` there — `sum` would silently turn + // "1.2k users" into "45k events". + metric: + input.metric ?? (input.chartType === 'metric' ? 'count' : 'sum'), previous: input.previous ?? false, ...(input.lineType ? { lineType: input.lineType } : {}), ...(input.limit ? { limit: input.limit } : {}), diff --git a/apps/start/src/components/report-chart/metric/chart.tsx b/apps/start/src/components/report-chart/metric/chart.tsx index 8d5a96c1b..c37dca3eb 100644 --- a/apps/start/src/components/report-chart/metric/chart.tsx +++ b/apps/start/src/components/report-chart/metric/chart.tsx @@ -12,7 +12,7 @@ interface Props { export function Chart({ data }: Props) { const { isEditMode, - report: { unit }, + report: { metric, unit }, } = useReportChartContext(); const { series } = useVisibleSeries(data, { limit: isEditMode ? 20 : 4 }); return ( @@ -27,7 +27,7 @@ export function Chart({ data }: Props) { ); diff --git a/apps/start/src/components/report-chart/metric/metric-card.tsx b/apps/start/src/components/report-chart/metric/metric-card.tsx index 334091bfc..e98b1d63d 100644 --- a/apps/start/src/components/report-chart/metric/metric-card.tsx +++ b/apps/start/src/components/report-chart/metric/metric-card.tsx @@ -58,7 +58,11 @@ export function MetricCard({ const number = useNumber(); const renderValue = (value: number | undefined, unitClassName?: string) => { - if (!value) { + // A genuine 0 is a real value, not a missing one — `min` is 0 whenever the + // range contains an empty bucket. Only absent metrics render as N/A, which + // still matters: getAggregateChartSql never selects total_count, so `count` + // really is undefined for bar/pie series. + if (value === undefined || value === null) { return
N/A
; } diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts index 9f88a1eb8..87df79de7 100644 --- a/apps/start/src/components/report/reportSlice.ts +++ b/apps/start/src/components/report/reportSlice.ts @@ -13,6 +13,7 @@ import type { IChartEventFilter, IChartEventItem, IChartLineType, + IChartMetric, IChartRange, IChartType, IInterval, @@ -209,6 +210,14 @@ export const reportSlice = createSlice({ state.dirty = true; state.chartType = action.payload; + // The Metric card has always shown the total unique count. Existing + // reports are backfilled to 'count' by migration, so default a newly + // switched one the same way rather than leaving old and new metric + // reports showing different aggregations. The picker overrides it. + if (action.payload === 'metric') { + state.metric = 'count'; + } + // Initialize sankey options if switching to sankey if (action.payload === 'sankey' && !state.options) { state.options = { @@ -301,6 +310,11 @@ export const reportSlice = createSlice({ state.unit = action.payload || undefined; }, + changeMetric(state, action: PayloadAction) { + state.dirty = true; + state.metric = action.payload; + }, + changeFunnelGroup(state, action: PayloadAction) { state.dirty = true; if (!state.options || state.options.type !== 'funnel') { @@ -445,6 +459,7 @@ export const { changePrevious, changeCriteria, changeUnit, + changeMetric, changeFunnelGroup, changeFunnelWindow, changeOptions, diff --git a/apps/start/src/components/report/sidebar/ReportSettings.tsx b/apps/start/src/components/report/sidebar/ReportSettings.tsx index 7f12ce348..777a93d28 100644 --- a/apps/start/src/components/report/sidebar/ReportSettings.tsx +++ b/apps/start/src/components/report/sidebar/ReportSettings.tsx @@ -7,11 +7,13 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { useAppParams } from '@/hooks/use-app-params'; import { useEventNames } from '@/hooks/use-event-names'; +import type { IChartMetric } from '@openpanel/validation'; import { useMemo } from 'react'; import { changeCriteria, changeFunnelGroup, changeFunnelWindow, + changeMetric, changePrevious, changeSankeyExclude, changeSankeyInclude, @@ -25,6 +27,7 @@ export function ReportSettings() { const chartType = useSelector((state) => state.report.chartType); const previous = useSelector((state) => state.report.previous); const unit = useSelector((state) => state.report.unit); + const metric = useSelector((state) => state.report.metric); const options = useSelector((state) => state.report.options); const retentionOptions = options?.type === 'retention' ? options : undefined; @@ -69,6 +72,11 @@ export function ReportSettings() { fields.push('stacked'); } + // `map` already reads report.metric; it just never had a way to set it. + if (chartType === 'metric' || chartType === 'map') { + fields.push('metric'); + } + return fields; }, [chartType]); @@ -137,6 +145,27 @@ export function ReportSettings() { /> )} + {fields.includes('metric') && ( +
+ + dispatch(changeMetric(val as IChartMetric))} + // Same labels the report table uses for these columns. + items={[ + { label: 'Unique', value: 'count' }, + { label: 'Sum', value: 'sum' }, + { label: 'Average', value: 'average' }, + { label: 'Min', value: 'min' }, + { label: 'Max', value: 'max' }, + ]} + /> +
+ )} {fields.includes('funnelGroup') && (