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
58 changes: 58 additions & 0 deletions .changeset/dataset-percent-scale-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"@objectstack/spec": minor
"@objectstack/service-analytics": minor
---

fix(spec,service-analytics): a percentage measure carries its SCALE, so a ratio of 1 is 100% (objectui#3136)

A `%` format string says how to PRINT a number, not what scale that number is
on — and the two readings collide at exactly `1`, which is both "100%" (a 0–1
ratio at full compliance) and "1%" (a single percentage point). With nothing on
the wire to tell them apart, renderers guessed from the value's magnitude and
resolved the collision the wrong way: an SLA / pass-rate dashboard reporting
`sla_rate = 1` displayed **"1.0%"** — "everything met the SLA" read as "1% met
the SLA" — on both the KPI card and the dataset table.

The scale was never actually unknowable; it just never left the server. A
measure declaring `derived: { op: 'ratio' }` is a 0–1 fraction *by definition*,
and a measure aggregating a `percent` field has whatever scale that field
stores. Both facts sit in metadata the enrichment pass already reads for the
ADR-0053 currency chain — which walks back to the source field, checks
`type === 'currency'`, and rides the resolved code onto the result column.
Percentages got no such treatment. They do now, through the same seam.

**`percentScaleOf(field)` (`@objectstack/spec/data`)** is the one place the
question is answered. A `percent` field stores a FRACTION unless it declares
`max > 1` (e.g. `min: 0, max: 100`), which marks whole-percent storage — the
same rule the percent edit widget already writes by, so a value round-trips.
Non-`percent` fields get no opinion: a plain `number` an author formatted with
a `%` keeps meaning exactly what their format string says.

**`AnalyticsResult.fields[].percentScale`** carries the answer: `'fraction'`
(`1` ⇒ "100%") or `'whole'` (`1` ⇒ "1%"), absent when the column is not a
percentage. `queryDataset` sets it from the measure's `derived.op === 'ratio'`
first, then the source field's scale. `currency` — emitted since ADR-0053 but
only ever written through a cast — is now declared on the same interface.

The config seam `measureCurrency` is renamed **`sourceFieldMeta`** and returns
`max` alongside `type`/`defaultCurrency`. The old name had already outgrown
itself: the date-bucketing path reads `type` through it to tell a `date`
dimension from a `datetime` one, and the percent chain is its third consumer.

Renderers that receive `percentScale` must scale by it rather than inferring
from the value; one that does not receive it (an older server) keeps whatever
fallback it has, so this is additive on the wire.

**Same widget family, second fix: an empty filtered group is a measured zero.**
A measure-scoped filter can exclude every row of a group the grid still lists,
and the database reports that by omitting the group from the supplementary
result — after the merge, indistinguishable from "not measured". For a COUNT or
a SUM it *is* measured: the answer is 0. `emptyGroupValueFor(aggregate)`
(`spec/data/aggregation-policy`) states which aggregates have an identity over
the empty set, and `queryDataset` fills it in once all supplementary merges are
done (a later measure's merge can append rows no earlier query saw). So
"0 of 12 paid" now reports `0` instead of blank, and a ratio built on it
computes to `0` instead of going null — the difference between a dashboard
saying "0% met the SLA" and saying nothing at all. `avg`/`min`/`max` keep their
null: there is nothing to average over an empty group, and flattening that to
zero would invent a measurement.
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,14 @@ export const RevenuePulseDashboard: Dashboard = {
{ id: 'col_accounts_by_month', type: 'column', title: 'Accounts Signed by Month', dataset: accountDs, dimensions: ['signed_on'], values: ['account_count'], chartConfig: cfg('column', 'signed_on', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 2, w: 6, h: 4 } },
{ id: 'donut_invoices_by_status', type: 'donut', title: 'Invoices by Status', dataset: invoiceDs, dimensions: ['status'], values: ['invoice_count'], chartConfig: cfg('donut', 'status', 'invoice_count'), layout: { x: 0, y: 6, w: 6, h: 4 } },
{ id: 'bar_accounts_by_industry', type: 'bar', title: 'Accounts by Industry', dataset: accountDs, dimensions: ['industry'], values: ['account_count'], chartConfig: cfg('bar', 'industry', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 6, w: 6, h: 4 } },

// ── Percent scale (objectui#3136) ────────────────────────────────────
// A ratio measure rendered on the two surfaces that disagreed: a KPI card
// and a grouped table. Grouping by status pins the Paid row's rate at
// exactly 1 — the boundary where "is this a 0–1 ratio or a percentage
// point?" cannot be answered from the number, and where guessing printed
// "1.0%". The column now carries its declared scale, so it reads 100.0%.
{ id: 'kpi_paid_rate', type: 'metric', title: 'Paid Rate', dataset: invoiceDs, values: ['paid_rate'], colorVariant: 'orange', layout: { x: 0, y: 10, w: 3, h: 2 } },
{ id: 'table_rate_by_status', type: 'table', title: 'Paid Rate by Status', dataset: invoiceDs, dimensions: ['status'], values: ['invoice_count', 'paid_count', 'paid_rate'], layout: { x: 3, y: 10, w: 9, h: 4 } },
],
};
12 changes: 12 additions & 0 deletions examples/app-showcase/src/ui/datasets/revenue-pulse.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export const ShowcaseInvoiceDataset = defineDataset({
measures: [
{ name: 'invoice_count', label: 'Invoices', aggregate: 'count' },
{ name: 'subtotal_sum', label: 'Subtotal', aggregate: 'sum', field: 'total', format: '0,0' },
// A RATE — the percent-scale case (objectui#3136). `paid_rate` is a 0–1
// ratio by construction, and grouping by `status` makes the Paid bucket
// exactly 1: the value a magnitude-guessing renderer printed as "1.0%"
// instead of "100.0%". The server annotates the column's scale from the
// `ratio` operator, so display no longer has to infer it.
{ name: 'paid_count', label: 'Paid Invoices', aggregate: 'count', filter: { status: 'paid' } },
{
name: 'paid_rate',
label: 'Paid Rate',
derived: { op: 'ratio', of: ['paid_count', 'invoice_count'] },
format: '0.0%',
},
],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function service(bucketByGranularity: Record<string, string>) {
return [{ created_at: bucketByGranularity[g ?? 'none'], task_count: 10, account_count: 10 }];
},
// `created_at` is a tz-naive calendar date → ranges are exact under any tz.
measureCurrency: (_o, f) => (f === 'created_at' ? { type: 'date' } : undefined),
sourceFieldMeta: (_o, f) => (f === 'created_at' ? { type: 'date' } : undefined),
});
return { svc, seen };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,12 @@ describe('AnalyticsService.queryDataset', () => {
});

// ── ADR-0053 currency chain (measure → field currencyConfig → tenant ctx) ──
function pricedSvc(rows: Array<Record<string, unknown>>, measureCurrency?: (o: string, f: string) => { type?: string; defaultCurrency?: string } | undefined) {
function pricedSvc(rows: Array<Record<string, unknown>>, sourceFieldMeta?: (o: string, f: string) => { type?: string; defaultCurrency?: string; max?: number } | undefined) {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async () => rows,
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
...(measureCurrency ? { measureCurrency } : {}),
...(sourceFieldMeta ? { sourceFieldMeta } : {}),
});
}
const moneyDataset = (measure: Record<string, unknown>) => DatasetSchema.parse({
Expand Down Expand Up @@ -161,6 +161,95 @@ describe('AnalyticsService.queryDataset', () => {
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBeUndefined();
});

// ── percent scale chain (objectui#3136) ───────────────────────────────────
// A "%" format says how to PRINT a number, not what scale it is on, and the
// two readings collide at exactly 1 ("100%" vs "1%"). The scale is answerable
// from metadata, so it rides onto the result column next to `currency`.
const rateDataset = (measures: Array<Record<string, unknown>>) => DatasetSchema.parse({
name: 'sla', label: 'SLA', object: 'ticket', include: [],
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
measures,
});

it('marks a derived RATIO as fraction-scaled — the 1.0 = 100% case', async () => {
// Two of two met: the ratio is exactly 1, the value that renders as "1.0%"
// when a renderer guesses the scale from the number's magnitude.
const svc = pricedSvc([{ status: 'met', met_count: 2, base_count: 2 }]);
const r = await svc.queryDataset(
rateDataset([
{ name: 'base_count', aggregate: 'count', label: 'Applicable' },
{ name: 'met_count', aggregate: 'count', field: 'met', label: 'Met' },
{ name: 'sla_rate', label: 'SLA rate', derived: { op: 'ratio', of: ['met_count', 'base_count'] }, format: '0.0%' },
]),
{ dimensions: ['status'], measures: ['base_count', 'met_count', 'sla_rate'] },
{ tenantId: 'o' } as ExecutionContext,
) as any;
expect(r.rows[0].sla_rate).toBe(1);
expect(r.fields.find((f: any) => f.name === 'sla_rate')?.percentScale).toBe('fraction');
// A count is not a percentage — annotating it would be a lie about the scale.
expect(r.fields.find((f: any) => f.name === 'base_count')?.percentScale).toBeUndefined();
});

it('a measure-scoped COUNT over a group with no matching rows is 0, not blank', async () => {
// The supplementary query for `met_count` returns only the groups that had
// a matching row; the database reports "none matched" by omitting the group
// entirely. That omission is a measured ZERO for a count — reporting it as
// missing blanked the cell and left the ratio null, hiding "0% of breached
// tickets met the SLA" on the one dashboard that exists to show it.
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
// The base pass sees both groups; the filtered pass only "met".
executeRawSql: async (_o, sql) => sql.includes('met_count')
? [{ status: 'met', met_count: 2 }]
: [{ status: 'met', base_count: 2 }, { status: 'breached', base_count: 3 }],
getReadScope: () => undefined,
});
const r = await svc.queryDataset(
rateDataset([
{ name: 'base_count', aggregate: 'count', label: 'Applicable' },
{ name: 'met_count', aggregate: 'count', field: 'met', label: 'Met', filter: { sla_met: true } },
{ name: 'sla_rate', label: 'SLA rate', derived: { op: 'ratio', of: ['met_count', 'base_count'] }, format: '0.0%' },
]),
{ dimensions: ['status'], measures: ['base_count', 'met_count', 'sla_rate'] },
{ tenantId: 'o' } as ExecutionContext,
) as any;
const breached = r.rows.find((x: any) => x.status === 'breached');
expect(breached.met_count).toBe(0);
expect(breached.sla_rate).toBe(0);
// The group that DID match is untouched — and is the 1.0 = 100% case.
expect(r.rows.find((x: any) => x.status === 'met').sla_rate).toBe(1);
});

it('inherits the SOURCE FIELD scale: a `max: 100` percent field is whole-scaled', async () => {
const svc = pricedSvc([{ status: 'open', allocation: 80 }], (_o, f) => f === 'allocation_percent' ? { type: 'percent', max: 100 } : undefined);
const r = await svc.queryDataset(
rateDataset([{ name: 'allocation', aggregate: 'avg', field: 'allocation_percent', label: 'Allocation', format: '0.0%' }]),
{ dimensions: ['status'], measures: ['allocation'] },
{ tenantId: 'o' } as ExecutionContext,
) as any;
expect(r.fields.find((f: any) => f.name === 'allocation')?.percentScale).toBe('whole');
});

it('inherits the SOURCE FIELD scale: a bare percent field is fraction-scaled', async () => {
const svc = pricedSvc([{ status: 'open', win: 0.75 }], (_o, f) => f === 'win_probability' ? { type: 'percent' } : undefined);
const r = await svc.queryDataset(
rateDataset([{ name: 'win', aggregate: 'avg', field: 'win_probability', label: 'Win', format: '0.0%' }]),
{ dimensions: ['status'], measures: ['win'] },
{ tenantId: 'o' } as ExecutionContext,
) as any;
expect(r.fields.find((f: any) => f.name === 'win')?.percentScale).toBe('fraction');
});

it('leaves a plain-number measure unannotated — its format string stays the only word', async () => {
const svc = pricedSvc([{ status: 'open', tax: 7 }], (_o, f) => f === 'tax_rate' ? { type: 'number', max: 100 } : undefined);
const r = await svc.queryDataset(
rateDataset([{ name: 'tax', aggregate: 'avg', field: 'tax_rate', label: 'Tax', format: '0.0%' }]),
{ dimensions: ['status'], measures: ['tax'] },
{ tenantId: 'o' } as ExecutionContext,
) as any;
expect(r.fields.find((f: any) => f.name === 'tax')?.percentScale).toBeUndefined();
});

it('enriches dimension columns with their dataset display label', async () => {
const labeled = DatasetSchema.parse({
name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'],
Expand Down Expand Up @@ -259,7 +348,7 @@ describe('AnalyticsService.queryDataset', () => {
// closed_at is a datetime instant → its month bucket boundary is that tz's
// MIDNIGHT INSTANT. June/July 2026 in New York are EDT (−04), so local
// midnight is 04:00 UTC.
measureCurrency: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined),
sourceFieldMeta: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined),
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
});
const result = await svc.queryDataset(
Expand All @@ -282,7 +371,7 @@ describe('AnalyticsService.queryDataset', () => {
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async () => [{ close_date: '2026-06', revenue: 100 }],
measureCurrency: (_o, f) => (f === 'close_date' ? { type: 'date' } : undefined),
sourceFieldMeta: (_o, f) => (f === 'close_date' ? { type: 'date' } : undefined),
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
});
const result = await svc.queryDataset(
Expand Down
39 changes: 28 additions & 11 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
CubeMeta,
DatasetSelection,
} from '@objectstack/spec/contracts';
import type { Cube, FilterCondition } from '@objectstack/spec/data';
import { percentScaleOf, type Cube, type FilterCondition } from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { Dataset } from '@objectstack/spec/ui';
import type { Logger } from '@objectstack/spec/contracts';
Expand Down Expand Up @@ -216,13 +216,20 @@ export interface AnalyticsServiceConfig {
*/
relationshipResolver?: RelationshipResolver;
/**
* ADR-0053 currency chain — resolve a measure's SOURCE FIELD currency
* metadata so a monetary measure that omits an explicit `currency` falls back
* to the field's declared currency, then the tenant default (`ctx.currency`).
* Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
* `undefined` for an unknown field. Non-`currency` fields never get a code.
* Resolve the metadata of a dimension's or measure's SOURCE FIELD on the
* dataset's base object — the one seam through which display semantics that
* live on the field reach the result columns. `undefined` for an unknown
* field. Feeds three chains:
*
* - ADR-0053 currency: a monetary measure that omits an explicit `currency`
* falls back to the field's declared currency, then the tenant default
* (`ctx.currency`). Non-`currency` fields never get a code.
* - Percent scale (objectui#3136): a measure over a `percent` field inherits
* that field's storage scale via `percentScaleOf`, so a renderer scales by
* declared metadata instead of guessing from the value.
* - Date bucketing: a date vs datetime dimension drills by the right bound.
*/
measureCurrency?: (object: string, field: string) => { type?: string; defaultCurrency?: string } | undefined;
sourceFieldMeta?: (object: string, field: string) => { type?: string; defaultCurrency?: string; max?: number } | undefined;
/** Pre-defined datasets to compile + register at construction (ADR-0021). */
datasets?: Dataset[];
/**
Expand Down Expand Up @@ -286,7 +293,7 @@ export class AnalyticsService implements IAnalyticsService {
private readonly datasetRegistry = new Map<string, CompiledDataset>();
/** Optional object-graph resolver used when compiling datasets. */
private readonly relationshipResolver?: RelationshipResolver;
private readonly measureCurrency?: AnalyticsServiceConfig['measureCurrency'];
private readonly sourceFieldMeta?: AnalyticsServiceConfig['sourceFieldMeta'];
/** Optional dimension display-label resolver (select options / lookup names). */
private readonly labelResolver?: DimensionLabelDeps;
/** ADR-0037 P3: pending-seed row resolver for draft data preview. */
Expand All @@ -309,7 +316,7 @@ export class AnalyticsService implements IAnalyticsService {

this.readScopeProvider = config.getReadScope;
this.relationshipResolver = config.relationshipResolver;
this.measureCurrency = config.measureCurrency;
this.sourceFieldMeta = config.sourceFieldMeta;
this.labelResolver = config.labelResolver;
this.draftRowsResolver = config.draftRowsResolver;
this.isRegisteredObject = config.isRegisteredObject;
Expand Down Expand Up @@ -657,7 +664,7 @@ export class AnalyticsService implements IAnalyticsService {
if (!d.field || d.type !== 'date') continue;
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
if (!granularity) continue;
const ftype = this.measureCurrency?.(dataset.object, d.field as string)?.type;
const ftype = this.sourceFieldMeta?.(dataset.object, d.field as string)?.type;
if (ftype === 'datetime') rangeDims.push({ d, granularity, instant: true });
else if (ftype === 'date') rangeDims.push({ d, granularity, instant: false });
else if (rangeTz === 'UTC') rangeDims.push({ d, granularity, instant: false });
Expand Down Expand Up @@ -746,14 +753,24 @@ export class AnalyticsService implements IAnalyticsService {
// number) never receive a currency code.
const fc = f as { currency?: string };
const mc = m as { currency?: string };
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
if (fc.currency == null) {
const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : undefined;
const monetary = !!mc.currency || meta?.type === 'currency';
if (monetary) {
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
if (resolved) fc.currency = resolved;
}
}
// Percent scale chain (objectui#3136) — the currency chain's sibling.
// A `%` format says how to PRINT a number, not what scale it is on, and
// the two readings collide at exactly 1 ("100%" vs "1%"). Both answers
// are in metadata, so answer here instead of leaving the renderer to
// guess from the value's magnitude: a `ratio` is a 0–1 fraction by
// definition, and any aggregate of a `percent` field (avg/min/max —
// sum is already rejected as incoherent) keeps that field's own scale.
if (f.percentScale == null) {
f.percentScale = m.derived?.op === 'ratio' ? 'fraction' : percentScaleOf(meta);
}
}
}

Expand Down
Loading
Loading