From 187a2f42e60e3b966d3e40b9f3687d7bad8377c9 Mon Sep 17 00:00:00 2001 From: dfliess Date: Tue, 18 Aug 2026 14:23:38 +0200 Subject: [PATCH] Compare a canvas KPI against a target measure A canvas KPI can only compare a measure with itself, over an earlier time range. But a budget, a target or a forecast is a second measure, so there is no way to render "revenue vs target" today, and those cards end up drawn by hand as Vega custom charts. `measure_comparisons` says which measure a KPI is compared against: ```yaml kpi_grid: metrics_view: sales measures: [revenue] measure_comparisons: - measure: revenue compare_to: target_revenue comparison: [previous, percent_change] ``` The comparison query then asks for that measure over the same time range, instead of the same measure over an earlier range. The result is stored under the name of the first measure, so the existing rendering path does not change. Each pair is a list item, because other canvas widgets already store per-measure settings that way. Both measures must belong to the same metrics view, and the reconciler checks `compare_to`. An entry for a measure the grid no longer shows is ignored, and not an error: removing a measure in the inspector leaves its entry behind, and breaking the resource for that would leave a state nobody can fix from the UI, since the inspector does not show this option. It also works without a time dimension, which the time comparison does not. For a percentage measure use `delta`, since Rill already hides `percent_change` there, and `delta` gives the difference in points. --- .../build/dashboards/canvas-widgets/data.md | 17 +++ .../ai/instructions/data/resources/canvas.md | 27 ++++ runtime/canvas/component.go | 21 +++ runtime/canvas/component_test.go | 48 +++++++ .../canvas/components/kpi-grid/KPIGrid.svelte | 3 + .../canvas/components/kpi-grid/index.ts | 11 +- .../features/canvas/components/kpi/KPI.svelte | 4 +- .../canvas/components/kpi/KPIProvider.svelte | 121 ++++++++++++++---- .../features/canvas/components/kpi/index.ts | 3 + 9 files changed, 225 insertions(+), 30 deletions(-) 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; }