From 4209097b1efed1a635328fab03cb06e9d78bc916 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 15:18:59 +0500 Subject: [PATCH] fix(dashboard): compute 8.0 derived/ratio measures so Completion Rate isn't a row count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dataset measure that is *derived* (a ratio/difference/product/sum over other named measures) had no single source field, so `resolveDatasetWidget` fell back to `aggregate: "count"` and the KPI rendered the row count — "Completion Rate" showed "8" instead of a percentage. Resolve a derived measure into its component specs and compute it client-side from the fetched rows (`matchesMongoFilter` applies each component's own filter). A `ratio` is scaled to 0–100 and defaults to a `0%` format so a bare 0–1 quotient reads as "25%", not "0". Closes the follow-up flagged while building the 2-col KPI grid. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/hooks/useDashboardData.test.ts | 135 +++++++++++++++++++++++ components/renderers/types.ts | 55 ++++++++- hooks/useDashboardData.ts | 127 ++++++++++++++++++++- lib/query-builder.ts | 56 ++++++++++ 4 files changed, 367 insertions(+), 6 deletions(-) diff --git a/__tests__/hooks/useDashboardData.test.ts b/__tests__/hooks/useDashboardData.test.ts index e7dc91d..bf25bb8 100644 --- a/__tests__/hooks/useDashboardData.test.ts +++ b/__tests__/hooks/useDashboardData.test.ts @@ -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). @@ -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"] }, + }, ], }; @@ -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); + }); }); diff --git a/components/renderers/types.ts b/components/renderers/types.ts index 32174bb..afd8de3 100644 --- a/components/renderers/types.ts +++ b/components/renderers/types.ts @@ -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; 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; +} + +/** + * 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). */ diff --git a/hooks/useDashboardData.ts b/hooks/useDashboardData.ts index 18f07f5..b0d9a4b 100644 --- a/hooks/useDashboardData.ts +++ b/hooks/useDashboardData.ts @@ -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"; @@ -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 @@ -194,23 +241,84 @@ export function resolveDatasetWidget( const options = (widget.options ?? {}) as Record; 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[], + 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[], + 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 */ /* ------------------------------------------------------------------ */ @@ -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; diff --git a/lib/query-builder.ts b/lib/query-builder.ts index ed3c762..dc85dd2 100644 --- a/lib/query-builder.ts +++ b/lib/query-builder.ts @@ -381,6 +381,62 @@ export function mongoFilterToAst( return nodes.length === 1 ? (nodes[0] as unknown[]) : ["and", ...nodes]; } +/* ------------------------------------------------------------------ */ +/* Client-side row matching */ +/* ------------------------------------------------------------------ */ + +/** + * Match a single value against a Mongo operator. Mirrors the operator + * vocabulary of `MONGO_OP_TO_AST` so a filter authored for the data API + * evaluates identically client-side. + */ +const MONGO_OP_MATCHERS: Record boolean> = { + $eq: (a, b) => a === b, + $ne: (a, b) => a !== b, + $gt: (a, b) => (a as number) > (b as number), + $gte: (a, b) => (a as number) >= (b as number), + $lt: (a, b) => (a as number) < (b as number), + $lte: (a, b) => (a as number) <= (b as number), + $in: (a, b) => Array.isArray(b) && b.includes(a), + $nin: (a, b) => Array.isArray(b) && !b.includes(a), + $contains: (a, b) => String(a).includes(String(b)), + $startsWith: (a, b) => String(a).startsWith(String(b)), + $endsWith: (a, b) => String(a).endsWith(String(b)), +}; + +/** + * Evaluate a Mongo-style filter object against an in-memory record. Used to + * compute filtered measures client-side (e.g. a "completed" count that backs + * the numerator of a completion-rate ratio) without a second server round-trip. + * + * Supports bare-value equality (`{ status: "done" }`) and the operator forms in + * `MONGO_OP_MATCHERS`; relative-date macros are resolved the same way the + * server-bound AST resolves them. An absent/empty filter matches every record. + * Unknown operators are treated as a pass so an unsupported clause never + * silently excludes everything. + */ +export function matchesMongoFilter( + record: Record, + filter: Record | null | undefined, +): boolean { + if (!filter || typeof filter !== "object") return true; + return Object.entries(filter).every(([field, cond]) => { + const actual = record[field]; + if ( + cond !== null && + typeof cond === "object" && + !Array.isArray(cond) && + Object.keys(cond as object).some((k) => k.startsWith("$")) + ) { + return Object.entries(cond as Record).every(([op, raw]) => { + const matcher = MONGO_OP_MATCHERS[op]; + return matcher ? matcher(actual, resolveLeaf(raw)) : true; + }); + } + return actual === resolveLeaf(cond); + }); +} + /* ------------------------------------------------------------------ */ /* Field selection / projections */ /* ------------------------------------------------------------------ */