Skip to content
Merged
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
135 changes: 135 additions & 0 deletions __tests__/hooks/useDashboardData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,89 @@ describe("useWidgetQuery", () => {
expect(result.current.value).toBeUndefined();
});

it("computes a derived ratio metric as a 0–100 percentage", () => {
// 2 of 8 tasks complete → 25%. A `count` ratio has no source field, so the
// numerator is a *filtered* row count and the denominator the total count.
mockUseQuery.mockReturnValue({
data: {
records: [
{ id: "1", status: "done" },
{ id: "2", status: "done" },
{ id: "3", status: "open" },
{ id: "4", status: "open" },
{ id: "5", status: "open" },
{ id: "6", status: "open" },
{ id: "7", status: "open" },
{ id: "8", status: "open" },
],
},
isLoading: false,
});
const widget: DashboardWidgetMeta = {
name: "completion_rate",
object: "todo_task",
type: "metric",
derivedMetric: {
op: "ratio",
asPercent: true,
parts: [
{ aggregate: "count", filter: { status: "done" } },
{ aggregate: "count" },
],
},
};
const { result } = renderHook(() => useWidgetQuery(widget));
expect(result.current.value).toBe(25);
expect(result.current.isLoading).toBe(false);
});

it("returns a raw quotient for a non-percent ratio metric", () => {
// avg basket value = revenue / orders, no percent format → leave un-scaled.
mockUseQuery.mockReturnValue({
data: {
records: [
{ id: "1", revenue: 100 },
{ id: "2", revenue: 300 },
],
},
isLoading: false,
});
const widget: DashboardWidgetMeta = {
name: "avg_basket",
object: "orders",
type: "metric",
derivedMetric: {
op: "ratio",
asPercent: false,
parts: [
{ aggregate: "sum", field: "revenue" },
{ aggregate: "count" },
],
},
};
const { result } = renderHook(() => useWidgetQuery(widget));
expect(result.current.value).toBe(200); // (100 + 300) / 2
});

it("yields 0 for a ratio metric with a zero denominator", () => {
mockUseQuery.mockReturnValue({ data: { records: [] }, isLoading: false });
const widget: DashboardWidgetMeta = {
name: "completion_rate",
object: "todo_task",
type: "metric",
derivedMetric: {
op: "ratio",
asPercent: true,
parts: [
{ aggregate: "count", filter: { status: "done" } },
{ aggregate: "count" },
],
},
};
const { result } = renderHook(() => useWidgetQuery(widget));
expect(result.current.value).toBe(0);
});

it("counts rows per bucket for a count-aggregate chart (no valueField)", () => {
// The common dataset case: a `count` measure has no source field, so chart
// buckets must count rows — not aggregate an absent value field (→ all 0).
Expand Down Expand Up @@ -203,7 +286,14 @@ describe("resolveDatasetWidget", () => {
dimensions: [{ name: "status", field: "status", type: "string" }],
measures: [
{ name: "task_count", aggregate: "count" },
{ name: "completed_count", aggregate: "count", filter: { status: "done" } },
{ name: "est_hours", aggregate: "sum", field: "estimated_hours", format: "0.0" },
{
name: "completion_rate",
label: "Completion Rate",
format: "0%",
derived: { op: "ratio", of: ["completed_count", "task_count"] },
},
],
};

Expand Down Expand Up @@ -247,4 +337,49 @@ describe("resolveDatasetWidget", () => {
expect(resolved.span).toBe(2);
expect(resolved.chartConfig?.format).toBe("0.0");
});

it("resolves a derived ratio measure into a percent metric", () => {
const widget: DashboardWidgetMeta = {
name: "rate",
type: "metric",
dataset: "task_metrics",
values: ["completion_rate"],
layout: { w: 3 },
};
const resolved = resolveDatasetWidget(widget, dataset);
expect(resolved.object).toBe("todo_task");
// The ratio has no single source field, so the widget must not aggregate one.
expect(resolved.valueField).toBeUndefined();
expect(resolved.aggregate).toBe("count");
expect(resolved.chartConfig?.format).toBe("0%");
expect(resolved.derivedMetric).toEqual({
op: "ratio",
asPercent: true,
parts: [
{ aggregate: "count", field: undefined, filter: { status: "done" } },
{ aggregate: "count", field: undefined, filter: undefined },
],
});
});

it("defaults a format-less ratio measure to a `0%` percent metric", () => {
const ds: DatasetMeta = {
...dataset,
measures: [
{ name: "task_count", aggregate: "count" },
{ name: "completed_count", aggregate: "count", filter: { status: "done" } },
// No `format` declared on the derived measure.
{ name: "rate_no_fmt", derived: { op: "ratio", of: ["completed_count", "task_count"] } },
],
};
const widget: DashboardWidgetMeta = {
name: "rate",
type: "metric",
dataset: "task_metrics",
values: ["rate_no_fmt"],
};
const resolved = resolveDatasetWidget(widget, ds);
expect(resolved.chartConfig?.format).toBe("0%");
expect(resolved.derivedMetric?.asPercent).toBe(true);
});
});
55 changes: 54 additions & 1 deletion components/renderers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,69 @@ export interface DashboardWidgetMeta {
dimensions?: string[];
/** Grid placement (8.0 spec): `{ x, y, w, h }` in a 12-column grid. */
layout?: { x?: number; y?: number; w?: number; h?: number };
/**
* Resolved derived metric (8.0). When set, the widget's headline value is
* computed from `derivedMetric` (a combination of filtered component
* aggregates) rather than the single `aggregate`/`valueField`. Populated by
* `resolveDatasetWidget` from a dataset's derived measure (e.g. a ratio).
*/
derivedMetric?: DerivedMetricSpec;
}

/** Core aggregate operators the RN widget hook can evaluate client-side. */
export type MeasureAggregate = "count" | "sum" | "avg" | "min" | "max";

/** A measure (aggregation) declared by an analytics dataset (8.0 spec). */
export interface DatasetMeasure {
name: string;
label?: string;
aggregate?: "count" | "sum" | "avg" | "min" | "max";
/**
* Aggregate operator. Optional/ignored for a `derived` measure. The spec also
* defines `count_distinct`/`array_agg`/`string_agg`; the RN hook treats any
* value outside the core five as `count`.
*/
aggregate?: MeasureAggregate | string;
/** Source object field the aggregate runs over (absent for `count`). */
field?: string;
/**
* Row predicate this measure aggregates over (Mongo-style, as authored in
* dataset metadata, e.g. `{ status: "done" }`). Lets a single object back
* several filtered measures — e.g. the numerator of a completion rate.
*/
filter?: Record<string, unknown>;
format?: string;
/**
* Derived measure (8.0 / ADR-0021): combines OTHER measures (referenced by
* name) via an operator — e.g. `completion_rate = ratio(done_count,
* task_count)`. A derived measure has no source field of its own, so it must
* be computed from its component measures rather than a single aggregate.
*/
derived?: { op: DerivedMeasureOp; of: string[] };
}

/** Operator combining the component measures of a derived measure (8.0). */
export type DerivedMeasureOp = "ratio" | "sum" | "difference" | "product";

/**
* A base measure resolved into the inputs the RN widget hook aggregates
* client-side — one component of a derived measure.
*/
export interface ResolvedMetricComponent {
aggregate: MeasureAggregate;
field?: string;
filter?: Record<string, unknown>;
}

/**
* A derived metric resolved for client-side computation: aggregate each
* component from the fetched rows, then combine via `op`. `asPercent` scales a
* `ratio` (a 0–1 fraction) to 0–100 so the codebase's percent formatters
* (e.g. a `0%` pattern) render it correctly.
*/
export interface DerivedMetricSpec {
op: DerivedMeasureOp;
parts: ResolvedMetricComponent[];
asPercent?: boolean;
}

/** A dimension (groupable field) declared by an analytics dataset (8.0 spec). */
Expand Down
127 changes: 122 additions & 5 deletions hooks/useDashboardData.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useMemo } from "react";
import { useQuery } from "@objectstack/client-react";
import { mongoFilterToAst } from "~/lib/query-builder";
import { matchesMongoFilter, mongoFilterToAst } from "~/lib/query-builder";
import type {
DashboardWidgetMeta,
DatasetMeasure,
DatasetMeta,
DerivedMetricSpec,
MeasureAggregate,
ResolvedMetricComponent,
} from "~/components/renderers/types";
import type { WidgetDataPayload } from "~/components/renderers/DashboardViewRenderer";

Expand Down Expand Up @@ -167,6 +171,49 @@ function spanFromLayoutWidth(w: number | undefined): number {
return typeof w === "number" && w >= 8 ? 2 : 1;
}

/** Narrow a measure's aggregate to the core five the hook evaluates. */
function normalizeAggregate(agg: string | undefined): MeasureAggregate {
return agg === "sum" || agg === "avg" || agg === "min" || agg === "max"
? agg
: "count";
}

/**
* Resolve an 8.0 derived measure (`ratio`/`difference`/`product`/`sum` over
* other measures, referenced by name) into the component specs the widget hook
* aggregates client-side. A `ratio` is treated as a percentage when its format
* is a percent pattern (or when it has no format at all — a bare 0–1 quotient
* reads as "0"), so `asPercent` scales it to 0–100 for the percent formatters.
* Returns `undefined` when no component measures resolve.
*/
function resolveDerivedMeasure(
measure: DatasetMeasure,
dataset: DatasetMeta,
isPercent: boolean,
): DerivedMetricSpec | undefined {
const derived = measure.derived;
if (!derived) return undefined;

const parts: ResolvedMetricComponent[] = (derived.of ?? [])
.map((name) => dataset.measures?.find((m) => m.name === name))
// A derived measure references base measures only — skip any that don't
// resolve (or are themselves derived, which the hook can't nest).
.filter((m): m is DatasetMeasure => !!m && !m.derived)
.map((m) => ({
aggregate: normalizeAggregate(m.aggregate),
field: m.field,
filter: m.filter,
}));

if (parts.length === 0) return undefined;

return {
op: derived.op,
parts,
asPercent: derived.op === "ratio" && isPercent,
};
}

/**
* Resolve an 8.0-spec dashboard widget — which references an analytics
* `dataset` plus `values` (measures) / `dimensions` — into the object-query
Expand Down Expand Up @@ -194,23 +241,84 @@ export function resolveDatasetWidget(
const options = (widget.options ?? {}) as Record<string, unknown>;
const color = typeof options.color === "string" ? options.color : undefined;

// A `ratio` derived measure renders as a percentage; default its format to
// `0%` so a bare quotient (e.g. 0.25) still reads as "25%".
const format =
measure?.derived?.op === "ratio" ? measure.format ?? "0%" : measure?.format;
const derivedMetric = measure?.derived
? resolveDerivedMeasure(measure, dataset, !!format && format.includes("%"))
: undefined;
const aggregate = normalizeAggregate(measure?.aggregate);

return {
...widget,
object: dataset.object,
aggregate: measure?.aggregate ?? "count",
// `count` measures have no source field — count rows instead.
// A derived metric is computed from its components — its top-level aggregate
// is unused, so keep it inert (`count`).
aggregate: derivedMetric ? "count" : aggregate,
// `count` and derived measures have no single source field — count rows
// (the derived path ignores this) instead.
valueField:
measure && measure.aggregate !== "count" ? measure.field : undefined,
measure && !measure.derived && aggregate !== "count"
? measure.field
: undefined,
categoryField: dimension?.field,
span: widget.span ?? spanFromLayoutWidth(widget.layout?.w),
derivedMetric,
chartConfig: {
...options,
...(color ? { colors: [color] } : null),
...(measure?.format ? { format: measure.format } : null),
...(format ? { format } : null),
},
};
}

/* ------------------------------------------------------------------ */
/* Derived-metric computation (client-side) */
/* ------------------------------------------------------------------ */

/** Aggregate a single derived-metric component from the fetched rows. */
function computeMetricComponent(
records: Record<string, unknown>[],
comp: ResolvedMetricComponent,
): number {
const rows = comp.filter
? records.filter((r) => matchesMongoFilter(r, comp.filter))
: records;
// `count` (and any component without a source field) tallies rows.
if (comp.aggregate === "count" || !comp.field) return rows.length;
const field = comp.field;
const nums = rows.map((r) => Number(r[field])).filter((n) => !isNaN(n));
return applyAggregate(comp.aggregate, nums);
}

/**
* Combine a derived metric's component aggregates via its operator. A `ratio`
* guards against a zero denominator and scales to a 0–100 percentage when
* `asPercent` is set, matching the codebase's percent-formatting convention.
*/
function computeDerivedMetric(
records: Record<string, unknown>[],
spec: DerivedMetricSpec,
): number {
const vals = spec.parts.map((p) => computeMetricComponent(records, p));
switch (spec.op) {
case "ratio": {
const num = vals[0] ?? 0;
const den = vals[1] ?? 0;
const ratio = den === 0 ? 0 : num / den;
return spec.asPercent ? ratio * 100 : ratio;
}
case "difference":
return vals.reduce((acc, v, i) => (i === 0 ? v : acc - v), 0);
case "product":
return vals.reduce((acc, v) => acc * v, 1);
case "sum":
default:
return vals.reduce((acc, v) => acc + v, 0);
}
}

/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -275,6 +383,15 @@ export function useWidgetQuery(widget: DashboardWidgetMeta): WidgetDataPayload {
switch (type) {
case "metric":
case "kpi": {
// A derived measure (e.g. a completion-rate ratio) has no single source
// field — compute it from its filtered component aggregates.
if (widget.derivedMetric) {
return {
value: computeDerivedMetric(records, widget.derivedMetric),
isLoading: false,
};
}

// Derive a numeric value from the aggregate hint
const agg = widget.aggregate ?? "count";
const field = widget.valueField;
Expand Down
Loading
Loading