Skip to content
Open
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
17 changes: 17 additions & 0 deletions docs/docs/developers/build/dashboards/canvas-widgets/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions runtime/ai/instructions/data/resources/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions runtime/canvas/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
48 changes: 48 additions & 0 deletions runtime/canvas/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down
11 changes: 10 additions & 1 deletion web-common/src/features/canvas/components/kpi-grid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<KPIGridSpec> {
minSize = { width: 2, height: 2 };
defaultSize = { width: 6, height: 4 };
resetParams = ["measures"];
resetParams = ["measures", "measure_comparisons"];
type: CanvasComponentType = "kpi_grid";
component = KPIGrid;

Expand Down
4 changes: 1 addition & 3 deletions web-common/src/features/canvas/components/kpi/KPI.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,7 @@

{#if comparisonLabel}
<p class="text-sm text-fg-secondary break-words">
{m.kpi_vs_comparison({
comparison: comparisonLabel?.toLowerCase() ?? "",
})}
{m.kpi_vs_comparison({ comparison: comparisonLabel ?? "" })}
</p>
{/if}
{/if}
Expand Down
121 changes: 95 additions & 26 deletions web-common/src/features/canvas/components/kpi/KPIProvider.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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 }];

Expand Down Expand Up @@ -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,
{
Expand All @@ -126,26 +169,52 @@
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,
priority: 10,
},
{
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<string, unknown>)[
comparisonMeasureKey
],
},
})),
},
};

$: interval = Interval.fromDateTimes(
DateTime.fromISO(start ?? "").setZone(timeZone),
DateTime.fromISO(end ?? "").setZone(timeZone),
Expand All @@ -156,15 +225,15 @@
{measure}
{timeGrain}
{timeZone}
{showTimeComparison}
showTimeComparison={showTimeComparison || measureComparison}
{hasTimeSeries}
{comparisonLabel}
{interval}
sparkline={spec.sparkline}
{hideTimeRange}
comparisonOptions={spec.comparison}
primaryTotalResult={$totalQuery}
comparisonTotalResult={$comparisonTotalQuery}
{comparisonTotalResult}
primarySparklineResult={$primarySparklineQuery}
comparisonSparklineResult={$comparisonSparklineQuery}
{comparisonSparklineResult}
/>
3 changes: 3 additions & 0 deletions web-common/src/features/canvas/components/kpi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading