diff --git a/docs/docs/developers/build/dashboards/canvas-widgets/data.md b/docs/docs/developers/build/dashboards/canvas-widgets/data.md index 15e7d79af72a..a0d16a265e96 100644 --- a/docs/docs/developers/build/dashboards/canvas-widgets/data.md +++ b/docs/docs/developers/build/dashboards/canvas-widgets/data.md @@ -45,6 +45,25 @@ The aggregation grain works the same way with `grain`. Time-series charts and KP time_filters: grain=week ``` +To compare a measure against a target instead of against an earlier period, add +`measure_comparisons`. The comparison is made over the selected time range. +`compare_to` can be a measure of the same metrics view or one of the grid's +`adhoc_measures`. If it has no single total, because it has +`required_dimensions`, the card shows no comparison: + +```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 1da31029f4bf..9fbb93de75a3 100644 --- a/runtime/ai/instructions/data/resources/canvas.md +++ b/runtime/ai/instructions/data/resources/canvas.md @@ -323,6 +323,35 @@ 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 +``` + +`compare_to` can name a measure of the same metrics view or an entry in the +grid's `adhoc_measures`. A target with `required_dimensions` has no single +total, so the card shows no comparison for it. 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 affc1bcc26ec..0ed4a8cdf08b 100644 --- a/runtime/canvas/component.go +++ b/runtime/canvas/component.go @@ -475,6 +475,33 @@ func (v rendererValidator) validateKPIGrid(props map[string]any) error { } } + 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'") + } + measure, ok := pathutil.GetPathString(entry, "measure") + if !ok { + return errors.New("each entry in 'measure_comparisons' must include a 'measure' string") + } + // Checked against the metrics view, not against 'measures': an entry for a + // measure the grid no longer shows is inert, not an error. + if !v.hasMeasure(mv, measure, ephemeralNames) { + return fmt.Errorf("referenced measure_comparisons measure %q is not a measure in metrics view %q", measure, mvn) + } + compareTo, ok := pathutil.GetPathString(entry, "compare_to") + if !ok { + return errors.New("each entry in 'measure_comparisons' must include a 'compare_to' string") + } + if !v.hasMeasure(mv, compareTo, ephemeralNames) { + 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 f8e2cb2b5ecb..2f144ccc6b9e 100644 --- a/runtime/canvas/component_test.go +++ b/runtime/canvas/component_test.go @@ -799,6 +799,70 @@ 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") + + // Invalid: measure isn't a measure of the metrics view, e.g. a typo. + testruntime.PutFiles(t, rt, id, map[string]string{ + "c1.yaml": ` +type: component +kpi_grid: + metrics_view: mv1 + measures: + - y + measure_comparisons: + - measure: nonexistent + compare_to: z +`}) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 4, 1, 0) + testruntime.RequireReconcileErrorContains(t, rt, id, runtime.ResourceKindComponent, "c1", `measure_comparisons measure "nonexistent"`) + + // 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) { @@ -1703,6 +1767,30 @@ kpi_grid: testruntime.ReconcileParserAndWait(t, rt, id) testruntime.RequireReconcileState(t, rt, id, 4, 0, 0) + // A kpi_grid comparing a measure against a ephemeral measure, and a ephemeral + // measure against a measure, should be valid. + testruntime.PutFiles(t, rt, id, map[string]string{ + "c1.yaml": ` +type: component +kpi_grid: + metrics_view: mv1 + measures: [y, profit] + measure_comparisons: + - measure: y + compare_to: target + - measure: profit + compare_to: z + adhoc_measures: + - name: profit + display_name: Profit + expression: y - z + - name: target + display_name: Target + expression: z * 2 +`}) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 4, 0, 0) + // A leaderboard referencing a ephemeral measure should be valid. testruntime.PutFiles(t, rt, id, map[string]string{ "c1.yaml": ` 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 89ea6674cecd..94caa7764180 100644 --- a/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte +++ b/web-common/src/features/canvas/components/kpi-grid/KPIGrid.svelte @@ -32,6 +32,13 @@ time_filters: kpiGridProperties.time_filters, })); + $: comparisonMeasureNames = kpis.map( + (kpi) => + kpiGridProperties.measure_comparisons?.find( + (comparison) => comparison?.measure === kpi.measure, + )?.compare_to, + ); + $: filters = { time_filters: kpiGridProperties.time_filters, dimension_filters: kpiGridProperties.dimension_filters, @@ -72,6 +79,7 @@ {#if $timeAndFilterStore} { minSize = { width: 2, height: 2 }; defaultSize = { width: 6, height: 4 }; - resetParams = ["measures", "adhoc_measures"]; + resetParams = ["measures", "adhoc_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 a7d493e3e6cc..b9e3d0e1d578 100644 --- a/web-common/src/features/canvas/components/kpi/KPI.svelte +++ b/web-common/src/features/canvas/components/kpi/KPI.svelte @@ -342,9 +342,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 cee553bb1f95..e19449c33ef8 100644 --- a/web-common/src/features/canvas/components/kpi/KPIProvider.svelte +++ b/web-common/src/features/canvas/components/kpi/KPIProvider.svelte @@ -33,6 +33,10 @@ export let visible: boolean; // The exact-selection surface of the KPI (its component); see `canvasExactSurface`. export let surface: string; + // Measure to compare against over the primary time range (e.g. a target), + // instead of the time comparison. Set by the KPI grid from its + // `measure_comparisons`; it is not part of the `kpi` component spec. + export let comparisonMeasureName: string | undefined = undefined; const client = useRuntimeClient(); @@ -49,6 +53,11 @@ 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 }, @@ -78,14 +87,40 @@ // also wait for it. $: supportsTotal = !!measure && measureSupportsTotalsQuery(measure); + $: comparisonEphemeralDef = ephemeralMeasures?.find( + (def) => def.name === comparisonMeasureKey, + ); + $: comparisonMeasureStore = getMeasureForMetricView( + comparisonMeasureKey, + metricsViewName, + ); + $: comparisonMeasure = + $comparisonMeasureStore ?? + (comparisonEphemeralDef + ? ephemeralMeasureToSpecMeasure(comparisonEphemeralDef) + : undefined); + + // A comparison measure without a single total has no value to compare + // against, so the card shows no comparison at all. + $: comparisonSupportsTotal = + !!comparisonMeasure && measureSupportsTotalsQuery(comparisonMeasure); + + $: comparisonEnabled = measureComparison + ? comparisonSupportsTotal + : showTimeComparison; + $: showSparkline = sparkline !== "none" && hasTimeSeries; - $: showComparison = !!comparisonOptions?.length && showTimeComparison; + $: showComparison = !!comparisonOptions?.length && comparisonEnabled; - $: 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(); // Exact measures: the selection of this KPI's surface. $: exactContextStore = canvasExactMeasureContext( @@ -111,6 +146,34 @@ $: tsRequestEphemeralMeasures = tsEphemeralMeasures && applyExactMeasures(tsEphemeralMeasures, exactNames); + // The comparison measure is exact when the surface's selection holds it, + // so it has the same value as on its own card in the grid. + $: comparisonExactNames = measureComparison + ? requestExactNames([comparisonMeasureKey], $exactContextStore) + : exactNames; + $: comparisonQueryMeasures = measureComparison + ? applyExactMeasures( + mapEphemeralMeasuresForRequest( + [{ name: comparisonMeasureKey }], + ephemeralMeasures, + ), + comparisonExactNames, + ) + : queryMeasures; + $: ({ + measureNames: comparisonTsMeasureNames, + ephemeralMeasures: comparisonTsEphemeralMeasures, + } = measureComparison + ? splitTimeSeriesMeasures([comparisonMeasureKey], ephemeralMeasures) + : { measureNames: tsMeasureNames, ephemeralMeasures: tsEphemeralMeasures }); + $: comparisonTsExactMeasureNames = exactTimeSeriesMeasureNames( + comparisonTsMeasureNames, + comparisonExactNames, + ); + $: comparisonTsRequestEphemeralMeasures = + comparisonTsEphemeralMeasures && + applyExactMeasures(comparisonTsEphemeralMeasures, comparisonExactNames); + $: totalQuery = createQueryServiceMetricsViewAggregation( client, { @@ -139,27 +202,53 @@ client, { metricsView: metricsViewName, - measures: queryMeasures, - timeRange: comparisonTimeRange, + measures: comparisonQueryMeasures, + timeRange: measureComparison + ? { start, end, timeZone } + : comparisonTimeRange, where, priority: 50, }, { query: { - // Without a comparison interval the query would cover all time. - enabled: - !!comparisonTimeRange?.start && - !!comparisonTimeRange?.end && - showComparison && - isValid && - supportsTotal && - !!start && - !!end && - visible, + // Without a comparison interval the time comparison would cover all + // time; a measure comparison uses the primary range instead. + enabled: measureComparison + ? showComparison && + isValid && + supportsTotal && + visible && + (!hasTimeSeries || (!!start && !!end)) + : !!comparisonTimeRange?.start && + !!comparisonTimeRange?.end && + showComparison && + isValid && + supportsTotal && + !!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, { @@ -193,11 +282,11 @@ client, { metricsViewName, - measureNames: tsMeasureNames, - ephemeralMeasures: tsRequestEphemeralMeasures, - exactMeasureNames: tsExactMeasureNames, - timeStart: comparisonTimeRange?.start, - timeEnd: comparisonTimeRange?.end, + measureNames: comparisonTsMeasureNames, + ephemeralMeasures: comparisonTsRequestEphemeralMeasures, + exactMeasureNames: comparisonTsExactMeasureNames, + timeStart: measureComparison ? start : comparisonTimeRange?.start, + timeEnd: measureComparison ? end : comparisonTimeRange?.end, timeGranularity: timeGrain, timeZone, where, @@ -206,18 +295,45 @@ { query: { // Wait for the comparison interval and the grain, as the primary sparkline does. - enabled: - !!comparisonTimeRange?.start && - !!comparisonTimeRange?.end && - !!timeGrain && - isValid && - showSparkline && - showComparison && - visible, + enabled: measureComparison + ? !!start && + !!end && + !!timeGrain && + isValid && + showSparkline && + showComparison && + visible + : !!comparisonTimeRange?.start && + !!comparisonTimeRange?.end && + !!timeGrain && + 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), @@ -241,7 +357,7 @@ {precision} {timeGrain} {timeZone} - {showTimeComparison} + showTimeComparison={comparisonEnabled} {hasTimeSeries} {comparisonLabel} {interval} @@ -249,7 +365,7 @@ {hideTimeRange} comparisonOptions={spec.comparison} primaryTotalResult={$totalQuery} - comparisonTotalResult={$comparisonTotalQuery} + {comparisonTotalResult} primarySparklineResult={$primarySparklineQuery} - comparisonSparklineResult={$comparisonSparklineQuery} + {comparisonSparklineResult} /> diff --git a/web-common/src/features/dashboards/ephemeral-measures/canvas.spec.ts b/web-common/src/features/dashboards/ephemeral-measures/canvas.spec.ts index 25e88c96ec92..a57391046a1f 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/canvas.spec.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/canvas.spec.ts @@ -70,6 +70,24 @@ describe("removeMeasureFromComponentSpec", () => { }); }); + it("drops KPI comparisons that use the measure on either side", () => { + expect( + removeMeasureFromComponentSpec( + { + measures: ["revenue", "margin"], + measure_comparisons: [ + { measure: "revenue", compare_to: "target" }, + { measure: "target", compare_to: "revenue" }, + { measure: "margin", compare_to: "margin_target" }, + ], + }, + "target", + ), + ).toEqual({ + measure_comparisons: [{ measure: "margin", compare_to: "margin_target" }], + }); + }); + it("returns nothing when the measure is unused", () => { expect( removeMeasureFromComponentSpec( diff --git a/web-common/src/features/dashboards/ephemeral-measures/canvas.ts b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts index ca9dd160af3f..9ce9f5c1804f 100644 --- a/web-common/src/features/dashboards/ephemeral-measures/canvas.ts +++ b/web-common/src/features/dashboards/ephemeral-measures/canvas.ts @@ -42,9 +42,10 @@ export function ephemeralDefsToSpecs( * `undefined` value deletes the property). Handles plain lists (`measures`, * `columns`), the single-measure `measure` property, chart field configs * (`y.field`, `y.fields`, `color.field`, ...) and per-measure entries such as - * the pivot's `conditional_format`. Other properties are never touched even - * if a value equals the name: `metrics_view`, `title`, dimension lists, or a - * `comparison` list containing "delta". A field config whose only field was + * the pivot's `conditional_format` or the KPI grid's `measure_comparisons`, + * where the measure may be on either side. Other properties are never touched + * even if a value equals the name: `metrics_view`, `title`, dimension lists, or + * a `comparison` list containing "delta". A field config whose only field was * the measure is dropped entirely, matching the inspector's own remove action. */ export function removeMeasureFromComponentSpec( @@ -59,7 +60,10 @@ export function removeMeasureFromComponentSpec( const kept = value.filter((item: unknown) => MEASURE_LIST_KEYS.has(key) ? item !== name - : !(isRecord(item) && item["measure"] === name), + : !( + isRecord(item) && + MEASURE_ENTRY_KEYS.some((entryKey) => item[entryKey] === name) + ), ); if (kept.length !== value.length) changes[key] = kept; } else if (isRecord(value)) { @@ -88,6 +92,9 @@ export function removeMeasureFromComponentSpec( // Component spec lists whose string entries are measure names. const MEASURE_LIST_KEYS = new Set(["measures", "columns"]); +// Keys of per-measure list entries whose values are measure names. +const MEASURE_ENTRY_KEYS = ["measure", "compare_to"]; + function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); }