diff --git a/docs/docs/developers/build/dashboards/canvas-widgets/data.md b/docs/docs/developers/build/dashboards/canvas-widgets/data.md index 6c3809743e4a..c9844b4c1906 100644 --- a/docs/docs/developers/build/dashboards/canvas-widgets/data.md +++ b/docs/docs/developers/build/dashboards/canvas-widgets/data.md @@ -25,6 +25,23 @@ KPI grids display key performance indicators in a compact grid format with compa codeLanguage="yaml" /> +To compare a measure against a target instead of against an earlier period, add +`measure_comparisons`. Both measures must belong to the same metrics view, and +the comparison is made over the selected time range: + +```yaml +- kpi_grid: + metrics_view: auction_metrics + measures: + - requests + measure_comparisons: + - measure: requests + compare_to: target_requests + comparison: + - previous + - percent_change +``` + ## Leaderboard Leaderboards show ranked data with the top performers highlighted. diff --git a/runtime/ai/instructions/data/resources/canvas.md b/runtime/ai/instructions/data/resources/canvas.md index 5f0fbbefbd62..8153e662eaba 100644 --- a/runtime/ai/instructions/data/resources/canvas.md +++ b/runtime/ai/instructions/data/resources/canvas.md @@ -276,6 +276,33 @@ kpi_grid: hide_time_range: true ``` +**Against a target instead of the past:** + +Use `measure_comparisons` when the thing to compare against is another measure +of the same metrics view, such as a budget or a forecast. The comparison is +then made over the selected time range rather than an earlier one, and the +time comparison toggle no longer applies to that measure. + +```yaml +kpi_grid: + metrics_view: sales_metrics + measures: + - total_revenue + - gross_margin_pct + measure_comparisons: + - measure: total_revenue + compare_to: target_revenue + - measure: gross_margin_pct + compare_to: target_margin_pct + comparison: + - previous # here, the target's value + - percent_change +``` + +Both measures must live in the same metrics view. For a percentage measure use +`delta` rather than `percent_change`: the relative change of a percentage is +misleading, and Rill omits it, so `delta` gives the difference in points. + ### Leaderboard Display ranked dimension values by measures: diff --git a/runtime/canvas/component.go b/runtime/canvas/component.go index 090a31f11dd7..0216104df6d6 100644 --- a/runtime/canvas/component.go +++ b/runtime/canvas/component.go @@ -283,6 +283,27 @@ func validateKPIGrid(props map[string]any, metricsViews map[string]*runtimev1.Me } } + comparisons, ok := props["measure_comparisons"].([]any) + if !ok && props["measure_comparisons"] != nil { + return errors.New("renderer properties for kpi_grid must have 'measure_comparisons' as an array") + } + for _, c := range comparisons { + entry, ok := c.(map[string]any) + if !ok { + return errors.New("each entry in 'measure_comparisons' must be an object with 'measure' and 'compare_to'") + } + if _, ok := pathutil.GetPathString(entry, "measure"); !ok { + return errors.New("each entry in 'measure_comparisons' must include a 'measure' string") + } + compareTo, ok := pathutil.GetPathString(entry, "compare_to") + if !ok { + return errors.New("each entry in 'measure_comparisons' must include a 'compare_to' string") + } + if !metricsViewHasMeasure(mv, compareTo) { + return fmt.Errorf("referenced compare_to value %q is not a measure in metrics view %q", compareTo, mvn) + } + } + return nil } diff --git a/runtime/canvas/component_test.go b/runtime/canvas/component_test.go index 91e21d1e3109..ea802cf5eff0 100644 --- a/runtime/canvas/component_test.go +++ b/runtime/canvas/component_test.go @@ -724,6 +724,54 @@ kpi_grid: testruntime.ReconcileParserAndWait(t, rt, id) testruntime.RequireReconcileState(t, rt, id, 4, 1, 0) testruntime.RequireReconcileErrorContains(t, rt, id, runtime.ResourceKindComponent, "c1", "is not a measure") + + // Valid: a measure compared against another measure. + testruntime.PutFiles(t, rt, id, map[string]string{ + "c1.yaml": ` +type: component +kpi_grid: + metrics_view: mv1 + measures: + - y + measure_comparisons: + - measure: y + compare_to: z +`}) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 4, 0, 0) + + // Invalid: compare_to isn't a measure of the metrics view. + testruntime.PutFiles(t, rt, id, map[string]string{ + "c1.yaml": ` +type: component +kpi_grid: + metrics_view: mv1 + measures: + - y + measure_comparisons: + - measure: y + compare_to: nonexistent +`}) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 4, 1, 0) + testruntime.RequireReconcileErrorContains(t, rt, id, runtime.ResourceKindComponent, "c1", "compare_to") + + // Valid: an entry for a measure the grid no longer shows is inert, not an + // error. Removing a measure from the visual editor leaves one behind, and + // failing the resource for it would be a state the editor cannot undo. + testruntime.PutFiles(t, rt, id, map[string]string{ + "c1.yaml": ` +type: component +kpi_grid: + metrics_view: mv1 + measures: + - y + measure_comparisons: + - measure: z + compare_to: y +`}) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 4, 0, 0) } func TestValidateTable(t *testing.T) { diff --git a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte index 9cbef500dd6c..25affd7caf84 100644 --- a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte +++ b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte @@ -26,6 +26,9 @@ sparkline: kpiGridProperties.sparkline, hide_time_range: kpiGridProperties.hide_time_range, comparison: kpiGridProperties.comparison, + comparison_measure: kpiGridProperties.measure_comparisons?.find( + (comparison) => comparison?.measure === measure, + )?.compare_to, dimension_filters: kpiGridProperties.dimension_filters, time_filters: kpiGridProperties.time_filters, })); diff --git a/web-common/src/features/canvas/components/kpi-grid/index.ts b/web-common/src/features/canvas/components/kpi-grid/index.ts index fd1991830810..6f6a01e6788a 100644 --- a/web-common/src/features/canvas/components/kpi-grid/index.ts +++ b/web-common/src/features/canvas/components/kpi-grid/index.ts @@ -30,6 +30,14 @@ export const defaultComparisonOptions: ComponentComparisonOptions[] = [ "percent_change", ]; +// Per-measure comparison target persisted in the canvas YAML. A list (not a +// map) mirrors how per-measure config is expressed elsewhere in canvas. A +// measure listed here ignores the time comparison toggle. +export interface KPIMeasureComparisonSpec { + measure: string; + compare_to: string; +} + export interface KPIGridSpec extends ComponentCommonProperties, ComponentFilterProperties { @@ -41,12 +49,13 @@ export interface KPIGridSpec hide_time_range?: boolean; // Defaults to "delta" and "percent_change" comparison?: ComponentComparisonOptions[]; + measure_comparisons?: KPIMeasureComparisonSpec[]; } export class KPIGridComponent extends BaseCanvasComponent { minSize = { width: 2, height: 2 }; defaultSize = { width: 6, height: 4 }; - resetParams = ["measures"]; + resetParams = ["measures", "measure_comparisons"]; type: CanvasComponentType = "kpi_grid"; component = KPIGrid; diff --git a/web-common/src/features/canvas/components/kpi/KPI.svelte b/web-common/src/features/canvas/components/kpi/KPI.svelte index 7e4ebc9f8267..f5b228c5c38a 100644 --- a/web-common/src/features/canvas/components/kpi/KPI.svelte +++ b/web-common/src/features/canvas/components/kpi/KPI.svelte @@ -319,9 +319,7 @@ {#if comparisonLabel}

- {m.kpi_vs_comparison({ - comparison: comparisonLabel?.toLowerCase() ?? "", - })} + {m.kpi_vs_comparison({ comparison: comparisonLabel ?? "" })}

{/if} {/if} diff --git a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte index 959c89fec0a1..bd4bf85ae836 100644 --- a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte +++ b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte @@ -31,9 +31,15 @@ measure: measureName, sparkline, comparison: comparisonOptions, + comparison_measure: comparisonMeasureName, hide_time_range: hideTimeRange, } = spec); + // Compare against another measure over the primary time range, instead of + // against the same measure over the comparison range. + $: comparisonMeasureKey = comparisonMeasureName ?? ""; + $: measureComparison = comparisonMeasureKey !== ""; + $: ({ timeGrain, timeRange: { timeZone, start, end }, @@ -50,14 +56,25 @@ $: measureStore = getMeasureForMetricView(measureName, metricsViewName); $: measure = $measureStore; + $: comparisonMeasureStore = getMeasureForMetricView( + comparisonMeasureKey, + metricsViewName, + ); + $: comparisonMeasure = $comparisonMeasureStore; + $: showSparkline = sparkline !== "none" && hasTimeSeries; - $: showComparison = !!comparisonOptions?.length && showTimeComparison; + $: showComparison = + !!comparisonOptions?.length && (showTimeComparison || measureComparison); - $: comparisonLabel = - comparisonTimeRangeState?.selectedComparisonTimeRange?.name && - (TIME_COMPARISON[comparisonTimeRangeState?.selectedComparisonTimeRange.name] - ?.label as string | undefined); + $: comparisonLabel = measureComparison + ? (comparisonMeasure?.displayName ?? comparisonMeasureKey) + : comparisonTimeRangeState?.selectedComparisonTimeRange?.name && + ( + TIME_COMPARISON[ + comparisonTimeRangeState?.selectedComparisonTimeRange.name + ]?.label as string | undefined + )?.toLowerCase(); $: queryMeasures = [{ name: measureName }]; @@ -85,24 +102,50 @@ client, { metricsView: metricsViewName, - measures: queryMeasures, - timeRange: comparisonTimeRange, + measures: measureComparison + ? [{ name: comparisonMeasureKey }] + : queryMeasures, + timeRange: measureComparison + ? { start, end, timeZone } + : comparisonTimeRange, where, priority: 50, }, { query: { - enabled: - comparisonTimeRange && - showComparison && - isValid && - !!start && - !!end && - visible, + enabled: measureComparison + ? showComparison && + isValid && + visible && + (!hasTimeSeries || (!!start && !!end)) + : comparisonTimeRange && + showComparison && + isValid && + !!start && + !!end && + visible, }, }, ); + // KPI.svelte reads comparison values keyed by the primary measure name. + // Only rewritten once the data is in: spreading the result while loading or + // in error breaks TanStack Query's discriminated union. + $: comparisonTotalResult = !measureComparison + ? $comparisonTotalQuery + : !$comparisonTotalQuery.data + ? $comparisonTotalQuery + : { + ...$comparisonTotalQuery, + data: { + ...$comparisonTotalQuery.data, + data: $comparisonTotalQuery.data.data?.map((row) => ({ + ...row, + [measureName]: row[comparisonMeasureKey], + })), + }, + }; + $: primarySparklineQuery = createQueryServiceMetricsViewTimeSeries( client, { @@ -126,9 +169,9 @@ client, { metricsViewName, - measureNames: [measureName], - timeStart: comparisonTimeRange?.start, - timeEnd: comparisonTimeRange?.end, + measureNames: measureComparison ? [comparisonMeasureKey] : [measureName], + timeStart: measureComparison ? start : comparisonTimeRange?.start, + timeEnd: measureComparison ? end : comparisonTimeRange?.end, timeGranularity: timeGrain || V1TimeGrain.TIME_GRAIN_HOUR, timeZone, where, @@ -136,16 +179,42 @@ }, { query: { - enabled: - comparisonTimeRange && - isValid && - showSparkline && - showComparison && - visible, + enabled: measureComparison + ? isValid && + showSparkline && + showComparison && + visible && + !!start && + !!end + : comparisonTimeRange && + isValid && + showSparkline && + showComparison && + visible, }, }, ); + $: comparisonSparklineResult = !measureComparison + ? $comparisonSparklineQuery + : !$comparisonSparklineQuery.data + ? $comparisonSparklineQuery + : { + ...$comparisonSparklineQuery, + data: { + ...$comparisonSparklineQuery.data, + data: $comparisonSparklineQuery.data.data?.map((point) => ({ + ...point, + records: point.records && { + ...point.records, + [measureName]: (point.records as Record)[ + comparisonMeasureKey + ], + }, + })), + }, + }; + $: interval = Interval.fromDateTimes( DateTime.fromISO(start ?? "").setZone(timeZone), DateTime.fromISO(end ?? "").setZone(timeZone), @@ -156,7 +225,7 @@ {measure} {timeGrain} {timeZone} - {showTimeComparison} + showTimeComparison={showTimeComparison || measureComparison} {hasTimeSeries} {comparisonLabel} {interval} @@ -164,7 +233,7 @@ {hideTimeRange} comparisonOptions={spec.comparison} primaryTotalResult={$totalQuery} - comparisonTotalResult={$comparisonTotalQuery} + {comparisonTotalResult} primarySparklineResult={$primarySparklineQuery} - comparisonSparklineResult={$comparisonSparklineQuery} + {comparisonSparklineResult} /> diff --git a/web-common/src/features/canvas/components/kpi/index.ts b/web-common/src/features/canvas/components/kpi/index.ts index ab22f83da4eb..27fbda58c1db 100644 --- a/web-common/src/features/canvas/components/kpi/index.ts +++ b/web-common/src/features/canvas/components/kpi/index.ts @@ -98,5 +98,8 @@ export interface KPISpec sparkline?: "none" | "bottom" | "right"; // Defaults to "delta" and "percent_change" comparison?: ComponentComparisonOptions[]; + // Measure to compare against over the primary time range (e.g. a target), + // instead of the time comparison. Takes precedence over it when set. + comparison_measure?: string; hide_time_range?: boolean; }