diff --git a/.changeset/26602-delta-polarity.md b/.changeset/26602-delta-polarity.md new file mode 100644 index 00000000000..578c9d61193 --- /dev/null +++ b/.changeset/26602-delta-polarity.md @@ -0,0 +1,8 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +Clause-②: yes (widening) +Metadata-protocol: added + +A dataset-bound KPI tile and a cross-tab cell colour their period-over-period delta by the measure's declared `polarity` (`fields[].polarity` on the dataset answer, objectstack#26602). `'lower'` draws a rise red and a fall green, so a slower approval turnaround no longer reads as good news. `'neutral'` draws the delta in the muted text colour, with no green and no red. `'higher'`, and an undeclared polarity, keep the colours every delta had before: a rise green, a fall red. The arrow still shows which way the number moved under every polarity. diff --git a/.changeset/26602-measure-polarity.md b/.changeset/26602-measure-polarity.md new file mode 100644 index 00000000000..999bc549e24 --- /dev/null +++ b/.changeset/26602-measure-polarity.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): a dataset measure declares its `polarity`, so a lower-is-better KPI draws a rising delta as bad news + +Clause-②: yes (widening) +Metadata-protocol: added + +`DatasetMeasureSchema` declares one optional key, `polarity`, with three values (`DatasetMeasurePolarity`): `'higher'` (a rise is good), `'lower'` (a fall is good: turnaround days, overdue counts, cost) and `'neutral'` (neither direction is good or bad). It is declared on the measure, the semantic layer, so every widget over the measure agrees, and not on a widget's `compareTo`, which is the executor's closed comparison directive. An undeclared polarity keeps today's colours: a rise green, a fall red. Nothing infers a polarity from the measure's name. The near-miss keys `betterWhen`, `direction`, `goodDirection` and `trendDirection` are pointed at `polarity`, and the boolean spellings `higherIsBetter` / `lowerIsBetter` are answered with the three values. + +The dataset answer's column descriptor declares the same vocabulary as `fields[].polarity` (`AnalyticsResult` and `AnalyticsResultResponseSchema`), and the analytics service relays the measure's declared value onto it, on the live query and the draft-data preview alike, the measure's `__compare` column included. An undeclared measure and every dimension column carry no `polarity` key, so an answer over a dataset that declares none is unchanged. + +The dataset form's measures repeater offers `polarity`, labelled in every shipped locale (`@objectstack/platform-objects` metadata-form catalogs). diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 2edf78a9c76..d153285c455 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -474,7 +474,8 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member, **`fields[]` is the resolved presentation surface — read it first.** Each entry carries `name` and `type` and, when the producer declares them, `label`, `format`, `currency`, -`percentScale`, `builtinAggregate`, `aggregate` and `nullLabel`: the optional members +`percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction +of a measure's change is good news, relayed from the dataset measure): the optional members `AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`), mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to it at compile time. Optional means a given column may omit them — read them defensively, diff --git a/content/docs/data-modeling/analytics.mdx b/content/docs/data-modeling/analytics.mdx index ad1dd33ffbe..f9a89ae3e86 100644 --- a/content/docs/data-modeling/analytics.mdx +++ b/content/docs/data-modeling/analytics.mdx @@ -260,6 +260,10 @@ measure values by hand: result `fields`, so a KPI or chart legend reads "Total Spent / $616,000" rather than "spent_sum / 616000". The renderer applies the `format` at display time (it can't be baked into the numeric row value charts plot). + A measure that declares `polarity` (`higher`, `lower` or `neutral`: which + direction of change is good news) carries it on its column too, so a + period-over-period delta on a lower-is-better KPI draws a rise as bad news; + undeclared keeps a rise green and a fall red. ## Migrating from inline queries diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 22ade5ba762..4616c688802 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1558 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1559 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1558** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **202** | **1559** | 14 protocol modules | --- @@ -369,7 +369,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 171 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 172 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -382,7 +382,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | | [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | | [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | -| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetDurationUnit`, `DatasetMeasure`, `DatasetMeasureDuration`, `DerivedMeasureOp` | +| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetDurationUnit`, `DatasetMeasure`, `DatasetMeasureDuration`, `DatasetMeasurePolarity`, `DerivedMeasureOp` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | | [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` | diff --git a/content/docs/references/ui/dataset.mdx b/content/docs/references/ui/dataset.mdx index 9b42a2f47c5..5a9902358ab 100644 --- a/content/docs/references/ui/dataset.mdx +++ b/content/docs/references/ui/dataset.mdx @@ -1,7 +1,7 @@ --- title: Dataset schema — UI Protocol reference navTitle: Dataset -description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetDurationUnit and 3 more: every property with its type and default." +description: "Analytics Dataset — the one semantic layer. Reference for Dataset, DatasetDimension, DatasetDurationUnit and 4 more: every property with its type and default." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -34,8 +34,8 @@ layer is absorbed/retired in a later phase (D-A). ## TypeScript Usage ```typescript -import { DatasetSchema, DatasetDimensionSchema, DatasetDurationUnit, DatasetMeasureSchema, DatasetMeasureDurationSchema, DerivedMeasureOp } from '@objectstack/spec/ui'; -import type { Dataset, DatasetDimension, DatasetDurationUnit, DatasetMeasure, DatasetMeasureDuration } from '@objectstack/spec/ui'; +import { DatasetSchema, DatasetDimensionSchema, DatasetDurationUnit, DatasetMeasureSchema, DatasetMeasureDurationSchema, DatasetMeasurePolarity, DerivedMeasureOp } from '@objectstack/spec/ui'; +import type { Dataset, DatasetDimension, DatasetDurationUnit, DatasetMeasure, DatasetMeasureDuration, DatasetMeasurePolarity } from '@objectstack/spec/ui'; // Validate data const result = DatasetSchema.parse(data); @@ -88,6 +88,7 @@ const result = DatasetSchema.parse(data); | **filter** | `any` | optional | | | **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. | | **currency** | `string` | optional | Display currency code (ISO 4217) | +| **polarity** | `Enum<'higher' \| 'lower' \| 'neutral'>` | optional | Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) or neutral (no good/bad colour). Colours a period-over-period delta; undeclared keeps rise green, fall red. | | **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | | | **duration** | `{ from: string; to: string; unit: Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'> }` | optional | Time between two datetime columns of each record (`{ from, to, unit }`), reduced by `aggregate` (avg / min / max / sum). Records with either instant null are excluded. Replaces `field`. | @@ -143,6 +144,7 @@ const result = DatasetSchema.parse(data); | **filter** | `any` | optional | | | **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. | | **currency** | `string` | optional | Display currency code (ISO 4217) | +| **polarity** | `Enum<'higher' \| 'lower' \| 'neutral'>` | optional | Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) or neutral (no good/bad colour). Colours a period-over-period delta; undeclared keeps rise green, fall red. | | **derived** | `{ op: Enum<'ratio' \| 'sum' \| 'difference' \| 'product'>; of: string[] }` | optional | | | **duration** | `{ from: string; to: string; unit: Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'> }` | optional | Time between two datetime columns of each record (`{ from, to, unit }`), reduced by `aggregate` (avg / min / max / sum). Records with either instant null are excluded. Replaces `field`. | @@ -168,6 +170,17 @@ const result = DatasetSchema.parse(data); | **unit** | `Enum<'seconds' \| 'minutes' \| 'hours' \| 'days'>` | ✅ | Unit of the result: the measure answers a number of these (1.5 with "days" is a day and a half). | +--- + +## DatasetMeasurePolarity + +### Allowed Values + +* `higher` +* `lower` +* `neutral` + + --- ## DerivedMeasureOp diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 9c246dee426..79fc3ffaff5 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -103,7 +103,7 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot): | `layout` | `object` | optional | Grid position and size (auto-flowed into the grid when omitted) | | `chartConfig` | `object` | optional | Chart **appearance**: `title`, `subtitle`, `description`, `colors`, `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. The bound dataset decides which series exist and which column each one reads, so `type`, `xAxis`, `yAxis` and `series` are refused here — see below | | `colorVariant` | `enum` | optional | KPI/card accent color | -| `compareTo` | `object` | optional | Period-over-period comparison: `{ kind: 'previousPeriod' \| 'previousYear', dimension? }`. Omit `dimension` when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. | +| `compareTo` | `object` | optional | Period-over-period comparison: `{ kind: 'previousPeriod' \| 'previousYear', dimension? }`. Omit `dimension` when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. The delta is coloured by the measure's `polarity`: `'lower'` draws a rise red and a fall green, `'neutral'` draws neither; undeclared, a rise is green. | | `options` | `object` | optional | The query keys below. Presentation does not go here — see below | ### What `chartConfig` does **not** carry @@ -147,6 +147,7 @@ Presentation has declared homes instead: | You want | Write it here | | :--- | :--- | | A number's format or currency | The dataset **measure**'s `format` and `currency` | +| A KPI delta's good/bad colour (lower is better for turnaround, cost) | The dataset **measure**'s `polarity`: `'higher'`, `'lower'` or `'neutral'` | | A KPI tile's accent color | The widget's `colorVariant` | | A chart's look (colors, legend, data labels, height) | The widget's `chartConfig` | diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 2c4187f2884..ea12bcd1478 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2095,6 +2095,9 @@ export const enMetadataForms: NonNullable = { "measures.currency": { label: "Currency" }, + "measures.polarity": { + label: "Polarity" + }, "measures.derived": { label: "Derived From" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 0a1eeb3ceb7..008578bef98 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2095,6 +2095,9 @@ export const esESMetadataForms: NonNullable = "measures.currency": { label: "Moneda" }, + "measures.polarity": { + label: "Polaridad" + }, "measures.derived": { label: "Derivada de" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e7fe0665702..c84521afe5b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2095,6 +2095,9 @@ export const jaJPMetadataForms: NonNullable = "measures.currency": { label: "通貨" }, + "measures.polarity": { + label: "極性" + }, "measures.derived": { label: "派生元" }, diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 4ff100e4b22..c0ff8cd247b 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1230,7 +1230,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // each with its type-level display pair, authored in all three locales. // 689 since #26619: the action form's `disabledReason` row (why a greyed // action is unavailable) — one row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(689); + // 690 since #26602: the dataset form's measures repeater offers `polarity` + // (which direction of a KPI delta is good news) — one row label, authored + // in all three locales. + expect(translated.length, `${locale} positive control`).toBe(690); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts index 43117259709..c468a6dc4cd 100644 --- a/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts @@ -59,7 +59,7 @@ // echoes in two locales TODAY. It is OUT of this population, asserted by // name — a walk that over-reached into the object form would sweep in 32 // undecided leaves, and the assertion is what notices. `datasetForm` — -// the sibling semantic-layer panel, 39 leaves, 0 echoing — is excluded +// the sibling semantic-layer panel, 40 leaves, 0 echoing — is excluded // too, and that exclusion is asserted rather than trusted precisely // because a wrong sweep of it would NOT go red. // (2) INWARD, AUTHORED. 38 of this population's own 44 leaves come back @@ -897,7 +897,8 @@ describe('#19403 round 9 — the population, DERIVED from `reportForm`', () => { // exactly why the exclusion is asserted instead of trusted. // 38 since #25330: the dimensions repeater offers `nullLabel`. // 39 since the measures repeater offers `duration`. - expect(DATASET_LEAVES.length).toBe(39); + // 40 since the measures repeater offers `polarity`. + expect(DATASET_LEAVES.length).toBe(40); const datasetEchoes = DATASET_LEAVES.filter((l) => TRANSLATED_LOCALES.some(([, forms]) => forms.dataset?.[l.kind === 'section' ? 'sections' : 'fields']?.[l.path]?.[l.prop] === l.en), ); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index cc7e5b31c6c..e9ecba51319 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2095,6 +2095,9 @@ export const zhCNMetadataForms: NonNullable = "measures.currency": { label: "货币" }, + "measures.polarity": { + label: "极性" + }, "measures.derived": { label: "派生自" }, diff --git a/packages/services/service-analytics/src/__tests__/measure-column-polarity.test.ts b/packages/services/service-analytics/src/__tests__/measure-column-polarity.test.ts new file mode 100644 index 00000000000..bb6565513e6 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/measure-column-polarity.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `fields[].polarity` — a dataset measure's declared polarity reaches the + * renderer on the measure column's descriptor, and on its `__compare` column. + * + * The dashboard renderer (`DatasetWidget`) colours a period-over-period delta + * from the dataset answer alone: it reads `fields[]` for every column's display + * metadata and never fetches the dataset document. So "lower is better" for an + * approval-turnaround measure has to ride the descriptor, like `format` and + * `currency`, or a slower turnaround keeps being drawn green. + * + * The control half: a measure that declares no polarity, and every dimension + * column, carry no `polarity` key at all, so an answer over a dataset that never + * declares one is byte-identical to before. Each pin is asserted on both paths + * that produce a dataset answer: the live engine query and the draft preview. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; + +interface Approval extends Record { + status: string; + turnaround_days: number; + submitted_on: string; +} + +const ROWS: Approval[] = [ + { status: 'approved', turnaround_days: 4, submitted_on: '2026-02-03' }, + { status: 'approved', turnaround_days: 6, submitted_on: '2026-02-10' }, + { status: 'rejected', turnaround_days: 2, submitted_on: '2026-01-20' }, +]; + +const DATASET = DatasetSchema.parse({ + name: 'approval_ds', + label: 'Approvals', + object: 'approval', + dimensions: [ + { name: 'status', field: 'status', type: 'string', label: 'Status' }, + { name: 'submitted_on', field: 'submitted_on', type: 'date' }, + ], + measures: [ + { name: 'turnaround', aggregate: 'sum', field: 'turnaround_days', label: 'Turnaround', polarity: 'lower' }, + { name: 'approved_count', aggregate: 'count', polarity: 'higher' }, + { name: 'request_count', aggregate: 'count', polarity: 'neutral' }, + // Undeclared: no key on the wire. + { name: 'row_count', aggregate: 'count' }, + ], +}); + +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +function evaluateAggregate(opts: { groupBy?: unknown; aggregations?: unknown }) { + const groupBy = (opts.groupBy ?? []) as Array; + const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>; + const buckets = new Map; rows: Approval[] }>(); + for (const r of ROWS) { + const key: Record = {}; + for (const g of groupBy) { + const f = typeof g === 'string' ? g : g.field; + key[f] = r[f]; + } + const id = JSON.stringify(Object.values(key)); + let b = buckets.get(id); + if (!b) { b = { key, rows: [] }; buckets.set(id, b); } + b.rows.push(r); + } + return [...buckets.values()].map(({ key, rows }) => { + const row: Record = { ...key }; + for (const a of aggs) { + row[a.alias] = a.method === 'sum' + ? rows.reduce((s, r) => s + Number(r[a.field] ?? 0), 0) + : rows.length; + } + return row; + }); +} + +function svc(preview: boolean) { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options), + ...(preview ? { draftRowsResolver: async () => ROWS as Record[] } : {}), + }); +} + +type Field = Awaited>['fields'][number]; +const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f])); + +const MEASURES = ['turnaround', 'approved_count', 'request_count', 'row_count']; + +async function bothPaths(selection: Record) { + const live = await svc(false).queryDataset(DATASET, selection as never, CTX); + const preview = await svc(true).queryDataset(DATASET, selection as never, CTX, { previewDrafts: true }); + return [['live', byName(live.fields)], ['preview', byName(preview.fields)]] as const; +} + +describe('fields[].polarity — a dataset answer relays each measure\'s declared polarity', () => { + it('carries each declared value as written, on both paths', async () => { + for (const [path, fields] of await bothPaths({ dimensions: ['status'], measures: MEASURES })) { + expect(fields.turnaround?.polarity, path).toBe('lower'); + expect(fields.approved_count?.polarity, path).toBe('higher'); + expect(fields.request_count?.polarity, path).toBe('neutral'); + } + }); + + it('writes no polarity key on an undeclared measure, nor on a dimension column', async () => { + for (const [path, fields] of await bothPaths({ dimensions: ['status'], measures: MEASURES })) { + expect(fields.row_count, path).toBeDefined(); + expect('polarity' in (fields.row_count ?? {}), path).toBe(false); + expect(fields.status, path).toBeDefined(); + expect('polarity' in (fields.status ?? {}), path).toBe(false); + } + }); + + it('a `__compare` column carries its measure\'s polarity, on both paths', async () => { + const selection = { + dimensions: ['status'], + measures: ['turnaround', 'row_count'], + timeDimensions: [{ dimension: 'submitted_on', dateRange: ['2026-02-01', '2026-02-28'] }], + compareTo: { kind: 'previousPeriod', dimension: 'submitted_on' }, + }; + for (const [path, fields] of await bothPaths(selection)) { + expect(fields.turnaround__compare, path).toBeDefined(); + expect(fields.turnaround__compare?.polarity, path).toBe('lower'); + expect(fields.row_count__compare, path).toBeDefined(); + expect('polarity' in (fields.row_count__compare ?? {}), path).toBe(false); + } + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a42f37de8b9..591e96e94ff 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -3012,7 +3012,7 @@ export class AnalyticsService implements IAnalyticsService { /** * ADR-0021 — describe the result's COLUMNS from the dataset's own authored - * definition: a measure's `label` / `format` / `currency` / `percentScale` / + * definition: a measure's `label` / `format` / `polarity` / `currency` / `percentScale` / * `builtinAggregate` / `aggregate` and the `type` its aggregate really * returns, then a dimension column's header `label`. * @@ -3111,6 +3111,12 @@ export class AnalyticsService implements IAnalyticsService { // its `of` measures, so that `aggregate` describes nothing on the wire. if (f.aggregate == null && !m.derived && m.aggregate) f.aggregate = m.aggregate; if (f.format == null && m.format) f.format = m.format; + // [#26602] The measure's polarity, relayed as declared, so a renderer + // colours the period-over-period delta by which direction is good news. + // The `__compare` column gets it too (it is the same measure over the + // shifted window). Undeclared stays absent: nothing here guesses one + // from the measure's name or unit. + if (f.polarity == null && m.polarity) f.polarity = m.polarity; // Currency chain. A MONETARY measure resolves its display currency // from: explicit measure `currency` → the source field's FIXED // currency (`sourceFieldMeta().defaultCurrency`, relayed only under diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 5fe58b40574..dd9b551d5ff 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -158,6 +158,8 @@ "DatasetMeasure (type)", "DatasetMeasureDuration (type)", "DatasetMeasureDurationSchema (const)", + "DatasetMeasurePolarity (const)", + "DatasetMeasurePolarity (type)", "DatasetMeasureSchema (const)", "DatasetSchema (const)", "DateRangeDefaultRange (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index fbba5ebb228..ac29c549b06 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -405,6 +405,7 @@ "ui/DatasetMeasure:format", "ui/DatasetMeasure:label", "ui/DatasetMeasure:name", + "ui/DatasetMeasure:polarity", "ui/DatasetMeasureDuration:from", "ui/DatasetMeasureDuration:to", "ui/DatasetMeasureDuration:unit", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index ebce25a2a04..ad203b4f624 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -94,6 +94,7 @@ "DatasetMeasure": "ui/DatasetMeasure", "DatasetMeasureDuration": "ui/DatasetMeasureDuration", "DatasetMeasureDurationSchema": "ui/DatasetMeasureDuration", + "DatasetMeasurePolarity": "ui/DatasetMeasurePolarity", "DatasetMeasureSchema": "ui/DatasetMeasure", "DatasetSchema": "ui/Dataset", "DerivedMeasureOp": "ui/DerivedMeasureOp", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index a3810e732da..ee6372813ab 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -153,6 +153,7 @@ "DatasetMeasure": "src/ui/dataset.zod.ts#DatasetMeasure (type)", "DatasetMeasureDuration": "src/ui/dataset.zod.ts#DatasetMeasureDuration (type)", "DatasetMeasureDurationSchema": "src/ui/dataset.zod.ts#DatasetMeasureDurationSchema (const)", + "DatasetMeasurePolarity": "src/ui/dataset.zod.ts#DatasetMeasurePolarity (type)", "DatasetMeasureSchema": "src/ui/dataset.zod.ts#DatasetMeasureSchema (const)", "DatasetSchema": "src/ui/dataset.zod.ts#DatasetSchema (const)", "DateRangeDefaultRange": "src/ui/dashboard.zod.ts#DateRangeDefaultRange (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index a7091bd7773..151713915b5 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -50,6 +50,7 @@ "ui/DatasetDurationUnit", "ui/DatasetMeasure", "ui/DatasetMeasureDuration", + "ui/DatasetMeasurePolarity", "ui/DerivedMeasureOp", "ui/DocNavItem", "ui/ElementApiDataSource", diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index 79bb9a35d82..257564b90ba 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -123,6 +123,14 @@ "note": "measure-declared currency (ISO 4217) enriched onto result fields alongside label/format, so the renderer formats the amount with a locale-correct Intl symbol rather than a '$' baked into format. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `analytics-service.ts:531` had rotted onto a comment about a `400 INVALID_FIELD` refusal, ~830 lines above the chain. Re-closed by hand against c459da6bc; DATED for the first time (this file carried no `verifiedAt` anywhere, which the review accepting that re-anchoring sorts as OLDEST). 2026-09-27: evidence RE-WORDED (#20126) — the chain no longer cites the date/datetime-semantics ADR, which never names currency, and its middle step is the field's FIXED currency, which the plugin relays only under `currencyMode: 'fixed'` (#20091). Verdict unchanged: LIVE. Re-closed by hand against 1c8b320a8.", "verifiedAt": "2026-09-27" }, + "polarity": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "ui/packages/plugin-dashboard/src/DatasetWidget.tsx#deltaToneClass colours a period-over-period delta by the measure column's `fields[].polarity` (`lower`: a rise red and a fall green; `neutral`: no good/bad colour; `higher` and undeclared: a rise green and a fall red), read off the dataset answer for the metric tile's `compareTo` delta and the cross-tab cell's comparison; the arrow keeps the number's direction", + "producer": "packages/services/service-analytics/src/analytics-service.ts#enrichResultColumns — the column-description pass shared by the live query and the draft-data preview relays the authored `m.polarity` onto the measure column's descriptor as `fields[].polarity`, the `__compare` column included; an undeclared measure and every dimension column carry no key", + "note": "[#26602] declared on the measure (the semantic layer), not on the widget's `compareTo`: that slot is forwarded verbatim to the closed wire `DatasetCompareToSchema`. A lower-is-better KPI (turnaround days, overdue counts, cost) no longer draws a rise as good news." + }, "derived": { "status": "live", "evidence": "packages/services/service-analytics/src/dataset-compiler.ts#compileDataset (`derived.push({ name: m.name, op: m.derived.op, of: m.derived.of })` — both members read here, and a derived measure is deliberately NOT compiled into a Cube metric); packages/services/service-analytics/src/dataset-executor.ts#evaluateDerivedMeasures (post-aggregation arithmetic row by row, driven from `compiled.derived`)", diff --git a/packages/spec/liveness/state-counts/dataset.md b/packages/spec/liveness/state-counts/dataset.md index 2065c8e6415..ffaf19bda9b 100644 --- a/packages/spec/liveness/state-counts/dataset.md +++ b/packages/spec/liveness/state-counts/dataset.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `dataset` | 31 | 0 | 0 | 0 | 0 | 31 | +| `dataset` | 32 | 0 | 0 | 0 | 0 | 32 | diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 429d1a52c90..4ef8b1dcab0 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { AnalyticsQuerySchema } from '../data/analytics.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { AggregationFunction, DateGranularity } from '../data/query.zod'; +import { DatasetMeasurePolarity } from '../ui/dataset.zod'; import { strictObject } from '../shared/strict-object'; import { BaseResponseSchema } from './contract.zod'; @@ -135,6 +136,15 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema + '(`POST /analytics/dataset/query`) whose dimension declares one; absent on measure columns ' + 'and on a cube query answer. Display copy only: grouping is unchanged.', ), + polarity: DatasetMeasurePolarity.optional().describe( + 'Which direction of change is good news for a measure column: the dataset measure\'s ' + + '`polarity` (`higher` / `lower` / `neutral`), relayed as declared. A renderer colours a ' + + 'period-over-period delta by it: `lower` draws a rise as bad, `neutral` draws no good or ' + + 'bad colour. Set on a measure column of a dataset answer (`POST /analytics/dataset/query`, ' + + 'the live query and the draft-data preview alike) whose measure declares one, its ' + + '`__compare` column included; absent otherwise, on dimension columns, and on a cube query ' + + 'answer. Absent means a rise is drawn as good.', + ), })).describe('Column metadata'), sql: z.string().optional().describe('Executed SQL (if debug enabled)'), totals: z.array(z.object({ diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index a86b2f7df33..c30e04ff547 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -6,7 +6,7 @@ import type { FilterCondition } from '../data/filter.zod.js'; import type { AggregationFunction } from '../data/query.zod.js'; import type { PercentScale } from '../data/percent-scale.js'; import type { ExecutionContext } from '../kernel/execution-context.zod.js'; -import type { Dataset } from '../ui/dataset.zod.js'; +import type { Dataset, DatasetMeasurePolarity } from '../ui/dataset.zod.js'; /** * IAnalyticsService - Analytics / BI Service Contract @@ -149,6 +149,20 @@ export interface AnalyticsResult { * Display copy only: it never changes which rows fall in the bucket. */ nullLabel?: string; + /** + * [#26602] Which direction of change is good news for a MEASURE column: + * the dataset measure's `polarity` (`DatasetMeasureSchema`), relayed as + * declared by the column-description seam, the measure's `__compare` + * column included. A renderer colours a period-over-period delta by + * it: `higher` draws a rise good and a fall bad, `lower` the reverse, + * `neutral` neither. + * + * Present only on a measure column of a `queryDataset` answer whose + * dataset measure declares one; absent otherwise, on every dimension + * column, and on a `query` (cube) answer. Absent reads as a rise drawn + * good, the colouring every delta had before the key existed. + */ + polarity?: DatasetMeasurePolarity; }>; /** Generated SQL (if available) */ sql?: string; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 093c204e49e..b91a79ad65d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 797 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 798 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1570,6 +1570,7 @@ export type Iso_ui_dataset__DatasetDimensionSchema = Assert, z.infer< typeof M160.DatasetDurationUnit > >>; +export type Iso_ui_dataset__DatasetMeasurePolarity = Assert, z.infer< typeof M160.DatasetMeasurePolarity > >>; export type Iso_ui_dataset__DatasetMeasureDurationSchema = Assert, z.infer< typeof M160.DatasetMeasureDurationSchema > >>; export type Iso_ui_dataset__DatasetMeasureSchema = Assert, z.infer< typeof M160.DatasetMeasureSchema > >>; export type Iso_ui_dataset__DatasetSchema = Assert, z.infer< typeof M160.DatasetSchema > >>; @@ -1731,7 +1732,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 797 isomorphic pins', () => { + it('still declares all 798 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2652,7 +2653,12 @@ describe('ADR-0122 type-alias convention', () => { // 796 -> 797 is #26170: a hook's declared refusal entry, `HookRefusalSchema` // (`data/hook.zod.ts`), closed at `message` / `status` with no default or // transform, pinned here (Iso_data_hook__HookRefusalSchema). +1 added. - expect(pins).toHaveLength(797); + // 797 -> 798 is #26602: `DatasetMeasurePolarity` (`ui/dataset.zod.ts`), + // the measure's `higher` / `lower` / `neutral` polarity enum — no default + // and no transform — so it is pinned here + // (Iso_ui_dataset__DatasetMeasurePolarity) rather than given a `…Parsed` + // synonym. +1 added. + expect(pins).toHaveLength(798); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/src/ui/dataset-measure-polarity.test.ts b/packages/spec/src/ui/dataset-measure-polarity.test.ts new file mode 100644 index 00000000000..7aff4d95fab --- /dev/null +++ b/packages/spec/src/ui/dataset-measure-polarity.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `DatasetMeasureSchema.polarity` — which direction of change is good news for + * a measure, so a "lower is better" KPI colours a rising delta as bad (#26602). + * + * The key is optional and closed: the three peer values parse and keep their + * value, an absent key parses exactly as before it existed (no default is + * materialized, so the renderer's undeclared arm keeps today's colours), any + * other value is refused at the key, and the near-miss spellings an author + * reaches for are pointed at it. The wire descriptor (`fields[].polarity`) + * declares the same vocabulary as the measure, by construction. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetMeasurePolarity, DatasetMeasureSchema, DatasetSchema } from './dataset.zod'; +import { AnalyticsResultResponseSchema } from '../api/analytics.zod'; + +const measure = { name: 'turnaround', aggregate: 'avg' as const, field: 'turnaround_days' }; + +describe('DatasetMeasureSchema.polarity', () => { + it.each(['higher', 'lower', 'neutral'] as const)('accepts %s and keeps the value', (polarity) => { + const parsed = DatasetMeasureSchema.safeParse({ ...measure, polarity }); + expect(parsed.success).toBe(true); + expect(parsed.data?.polarity).toBe(polarity); + }); + + it('the closed vocabulary is exactly higher / lower / neutral', () => { + expect(DatasetMeasurePolarity.options).toEqual(['higher', 'lower', 'neutral']); + }); + + it('an absent polarity parses exactly as before: no key is materialized', () => { + const parsed = DatasetMeasureSchema.safeParse(measure); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(measure); + expect(Object.prototype.hasOwnProperty.call(parsed.data, 'polarity')).toBe(false); + }); + + it.each(['up', 'down', 'good', 'inverse', true])('refuses %j at the key', (polarity) => { + const parsed = DatasetMeasureSchema.safeParse({ ...measure, polarity }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['polarity']]]); + }); + + it.each(['betterWhen', 'direction'])('points the near-miss `%s` at `polarity`', (key) => { + const parsed = DatasetMeasureSchema.safeParse({ ...measure, [key]: 'lower' }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue).toBeDefined(); + expect(issue?.message).toContain('`polarity`'); + }); + + it.each(['lowerIsBetter', 'higherIsBetter'])('answers the boolean-flag spelling `%s` with the three values', (key) => { + const parsed = DatasetMeasureSchema.safeParse({ ...measure, [key]: true }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain("polarity: 'lower'"); + expect(issue?.message).toContain("polarity: 'neutral'"); + }); + + it('a whole dataset carrying it parses clean', () => { + const parsed = DatasetSchema.safeParse({ + name: 'approval_turnaround', + label: 'Approval Turnaround', + object: 'approval', + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [{ ...measure, polarity: 'lower' }], + }); + expect(parsed.success).toBe(true); + expect(parsed.data?.measures[0]?.polarity).toBe('lower'); + }); +}); + +describe('fields[].polarity on the dataset answer', () => { + const answer = (polarity: unknown) => ({ + success: true, + data: { rows: [], fields: [{ name: 'turnaround', type: 'number', polarity }] }, + }); + + it('declares the measure\'s own vocabulary', () => { + for (const polarity of DatasetMeasurePolarity.options) { + const parsed = AnalyticsResultResponseSchema.safeParse(answer(polarity)); + expect(parsed.success, polarity).toBe(true); + expect(parsed.data?.data.fields[0]?.polarity).toBe(polarity); + } + expect(AnalyticsResultResponseSchema.safeParse(answer('up')).success).toBe(false); + }); + + it('is optional: a descriptor without it parses as before', () => { + const parsed = AnalyticsResultResponseSchema.safeParse({ + success: true, + data: { rows: [], fields: [{ name: 'turnaround', type: 'number' }] }, + }); + expect(parsed.success).toBe(true); + expect(Object.prototype.hasOwnProperty.call(parsed.data?.data.fields[0], 'polarity')).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/dataset.form.ts b/packages/spec/src/ui/dataset.form.ts index 9f729c412ee..8cc9af93cf1 100644 --- a/packages/spec/src/ui/dataset.form.ts +++ b/packages/spec/src/ui/dataset.form.ts @@ -90,6 +90,7 @@ export const datasetForm = defineForm({ { field: 'filter', label: 'Filter' }, { field: 'format', label: 'Format' }, { field: 'currency', label: 'Currency' }, + { field: 'polarity', label: 'Polarity' }, { field: 'derived', label: 'Derived From' }, { field: 'duration', label: 'Duration' }, ], diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index c38a745d969..1271b7ec733 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -87,6 +87,16 @@ const DATASET_NO_SQL = + '`duration: { from, to, unit }`, which aggregates the time between two datetime columns of each record. ' + 'Joins are compiled from `Dataset.include` — you never write an ON clause.'; +/** + * [#26602] A boolean "is higher better?" flag has no word for a measure where + * neither direction is good news, so the measure's polarity is one key with + * three peer values instead. + */ +const DATASET_POLARITY_NOT_A_FLAG = + 'a measure declares which direction is good news with `polarity`, not a boolean: ' + + "`polarity: 'higher'` (a rise is good), `polarity: 'lower'` (a fall is good: turnaround days, overdue " + + "counts, cost) or `polarity: 'neutral'` (neither, the delta is drawn without a good or bad colour)."; + /** * A dataset dimension's and measure's `field` is a COLUMN REFERENCE, never a * SQL expression (#21220; ADR-0021 "zero raw SQL / zero raw expressions", @@ -219,6 +229,23 @@ export const DerivedMeasureOp = z.enum(['ratio', 'sum', 'difference', 'product'] */ export const DatasetDurationUnit = z.enum(['seconds', 'minutes', 'hours', 'days']); +/** + * [#26602] Which direction of change is good news for a measure, its + * POLARITY. Read by every presentation that colours a period-over-period + * delta (a KPI tile's `compareTo` arrow, a cross-tab cell's comparison): + * + * - `higher`: a rise is good (green), a fall is bad (red). Revenue, deals won. + * - `lower`: a fall is good (green), a rise is bad (red). Turnaround days, + * overdue counts, open deviations, cost. + * - `neutral`: neither direction is good or bad. The delta keeps its arrow and + * percentage, drawn in the muted text colour, with no green and no red. + * + * The arrow always shows which way the NUMBER moved; polarity decides only the + * colour. The values name the better direction rather than a sign or a + * boolean, so `neutral` is a peer value and not a third state of a flag. + */ +export const DatasetMeasurePolarity = z.enum(['higher', 'lower', 'neutral']); + /** * [#25778] Seconds per {@link DatasetDurationUnit} — the one conversion every * consumer divides by (the SQL compiler, the draft-preview evaluator, a @@ -350,11 +377,19 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ // [#25778] The column-pair measure. elapsed: 'duration', timeBetween: 'duration', + // [#26602] Which direction is good news. `betterWhen` is the spelling the + // card that asked for the key proposed. + betterWhen: 'polarity', + direction: 'polarity', + goodDirection: 'polarity', + trendDirection: 'polarity', }, guidance: { sql: DATASET_NO_SQL, expression: DATASET_NO_SQL, formula: DATASET_NO_SQL, + higherIsBetter: DATASET_POLARITY_NOT_A_FLAG, + lowerIsBetter: DATASET_POLARITY_NOT_A_FLAG, description: 'a measure has no `description` — its author-facing text is `label`. `description` is declared on the DATASET itself; put the explanation there.', }, @@ -425,6 +460,25 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ * layer) when the aggregated field is a fixed-currency amount. */ currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)').meta({ title: 'Currency' }), + /** + * [#26602] Which direction of change is good news for this measure — see + * {@link DatasetMeasurePolarity}. Carried onto the result column as + * `fields[].polarity` (its `__compare` column too), where a presentation + * colours the period-over-period delta by it: `lower` draws a rise red and a + * fall green, `neutral` draws both muted. + * + * Declared on the measure, the semantic layer, so every widget over the + * measure agrees. A count whose meaning flips with a filter (overdue vs + * resolved records) is two measures, each with its own measure `filter` and + * its own polarity. + * + * Undeclared, a delta keeps the colours it always had: a rise green, a fall + * red. Nothing infers a polarity from the measure's name or its unit. + */ + polarity: DatasetMeasurePolarity.optional().describe( + 'Which direction of change is good news: higher (rise green, fall red), lower (fall green, rise red) ' + + 'or neutral (no good/bad colour). Colours a period-over-period delta; undeclared keeps rise green, fall red.', + ).meta({ title: 'Polarity' }), /** * Derived measure — computed from OTHER measures in this dataset by name * only. e.g. `{ op: 'ratio', of: ['won_amount', 'total_amount'] }`. @@ -707,6 +761,7 @@ export type DatasetDimension = z.input; export type DatasetMeasure = z.input; export type DatasetMeasureDuration = z.input; export type DatasetDurationUnit = z.input; +export type DatasetMeasurePolarity = z.input; export type DerivedMeasureOpValue = z.input; export type Dataset = z.input; diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 52e29d2244e..36af4edd044 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -766,7 +766,11 @@ export const CEILINGS = new Map([ // nothing to delete to fund it; the whole addition is raised, pinned AT the measurement. // Maintainer ruling 2026-10-27 (Warren, decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, // option A tapped at 01:21Z), verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1348], + // 1348 -> 1361 (#26602), under the same ruling: the `duration` prompt also asks for the + // measure's `polarity`, part funded by deleting two parentheticals that restated an + // expected output (eval 2's, and eval 8's own gloss on the asserted value); the balance + // is raised, pinned AT the measurement (5443 bytes). + ['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1361], // INITIAL measurement (#26633): the third authored eval file — ONE // report-authoring prompt for `dateRange` on a `type: 'date'` dataset // dimension and `exportOptions` csv / xlsx under the base object's @@ -818,7 +822,9 @@ export const CEILINGS = new Map([ // is paid in full by deleting 78 tokens of restatement — the example's four-line comment // that restated the Dataset-Bound Widgets section — so the file nets -12 and the ceiling // follows it down to the new measurement, the ratchet's ordinary direction. - ['skills/objectstack-ui/rules/dashboards.md', 6239], + // 6239 -> 6221 (#26602): LOWERED. The metric-widget bullet teaches the measure's `polarity`, + // paid inside the file; the ceiling follows the file down to the measurement (24881 bytes). + ['skills/objectstack-ui/rules/dashboards.md', 6221], // 3011 -> 3141 (#19715, card item 4): a 5-line table of contents at the top, // anchors to the file's existing headings, nothing else changed. +132 tokens, // of which the row's 2 tokens of headroom absorb 2; the CEILING moves 130. diff --git a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json index 73872499f86..38be7f9b438 100644 --- a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json +++ b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json @@ -16,7 +16,7 @@ "id": 2, "prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.", "expected_output": "Recognizes a rolling window as BEYOND the dataset envelope (no window/having grammar; compareTo only shifts a period/year): escalates to a stored rolling field or app code, not a Cube, and never passes off a date bucket or a plain measure as a moving window.", - "note": "'dateGranularity' in must_not_contain is deliberately strict (date bucketing is the classic wrong answer); it can flag a correct answer that merely mentions the key — a review cue, not an automatic fail.", + "note": "'dateGranularity' in must_not_contain is deliberately strict; it can flag a correct answer that merely mentions the key — a review cue, not an automatic fail.", "files": [], "assertions": { "must_contain": ["window"], @@ -75,11 +75,11 @@ }, { "id": 8, - "prompt": "Add a KPI of the average days from an opportunity's created_at to its closed_at (both datetime). Emit the metadata.", - "expected_output": "A dataset measure `{ aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' } }`; no hook-kept stored number.", + "prompt": "Add a KPI of the average days from an opportunity's created_at to its closed_at (both datetime), vs the previous period; slower is worse. Emit the metadata.", + "expected_output": "A dataset measure `{ aggregate: 'avg', duration: { from: 'created_at', to: 'closed_at', unit: 'days' }, polarity: 'lower' }` under `compareTo`; no hook-kept stored number.", "files": [], "assertions": { - "must_contain": ["duration", "unit: 'days'"], + "must_contain": ["duration", "unit: 'days'", "polarity: 'lower'"], "must_not_contain": ["beforeUpdate"] } } diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 216d40c51f3..6c7a0f43426 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -245,12 +245,10 @@ compareTo: { kind: 'previousPeriod' } // one dated di compareTo: { kind: 'previousYear', dimension: 'close_date' } // several — say which ``` -* **Metric widgets** — the prior-period value renders as a small caption - beneath the headline number, alongside a green/red delta arrow and an - i18n trend label resolved from the comparison kind (e.g. `vs previous - period`, `vs previous year`). Authors do *not* - hand-author a trend: `options.trend` reaches no renderer on a - dataset-bound widget; `compareTo` is the trend. +* **Metric widgets** — a delta arrow plus a trend label from the kind (`vs + previous period`). Its colour follows the measure's `polarity`: `'lower'` + (turnaround, cost) draws a rise red, `'neutral'` uncoloured; undeclared = + rise green. `options.trend` reaches no renderer; `compareTo` is the trend. * **Cartesian charts** (`line` / `area` / `bar` / `horizontal-bar` / `scatter`) — the comparison series is appended after the primary series with `variant: 'comparison'`, muted per family (dashed `'4 4'` on @@ -336,7 +334,7 @@ const signedByMonth: DashboardWidget = { id: 'signed_by_month', type: 'line', `options` is an open bag, but a dataset-bound widget reads no other key: `icon`, `trend`, `format`, `color` style nothing, and `os validate` warns on each (`unconsumed-widget-option`). -Presentation lives on the measure (`format` / `currency`), `colorVariant` and `chartConfig`. +Presentation lives on the measure (`format` / `currency` / `polarity`), `colorVariant` and `chartConfig`. These five change the dataset SQL; a typo (`sortDirection`) parses too, and draws that warning. | Key | Value | Effect | diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 4ee3579ed0c..1bef8680456 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -92,7 +92,7 @@ import { AlertTriangle, ShieldAlert, Download, ArrowUpIcon, ArrowDownIcon, Minus // surface (see that component's header for why it is dashboard-local). import { WidgetEmptyState } from './WidgetEmptyState'; import { useFilterScope } from '@object-ui/react'; -import { resolveFilterPlaceholders, computeMetricDelta, formatMetricDeltaPercent } from './utils'; +import { resolveFilterPlaceholders, computeMetricDelta, formatMetricDeltaPercent, type MetricDelta } from './utils'; import { metricAccentTextClass } from './colorVariants'; import { METRIC_TILE_CONTAINER_CLASS, @@ -265,6 +265,32 @@ const COMPARE_KIND_TREND_KEY = { previousYear: 'vsLastYear', } as const satisfies Record; +/** + * The good/bad colour of a period-over-period delta, judged by the measure's + * declared polarity (objectstack#26602). The dataset measure's `polarity` + * reaches this widget on the answer's column descriptor (`fields[].polarity`), + * relayed by the server like `format` and `currency`. + * + * - `lower`: a fall is good news (green) and a rise is bad (red). Turnaround + * days, overdue counts, cost: a slower approval no longer reads as a win. + * - `neutral`: neither direction is good or bad, so no tone at all. + * - `higher`, and an UNDECLARED polarity: a rise green, a fall red, the + * colours every delta had before the key existed. Nothing here guesses a + * polarity from the measure's name or unit. + * + * The arrow is not this function's business: it always shows which way the + * number moved. A flat delta (`direction: 'neutral'`) takes no tone under any + * polarity, as before. + */ +function deltaToneClass( + direction: MetricDelta['direction'], + polarity: DatasetResultField['polarity'], +): string | undefined { + if (direction === 'neutral' || polarity === 'neutral') return undefined; + const good = polarity === 'lower' ? direction === 'down' : direction === 'up'; + return good ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'; +} + /** * ISO calendar date, optionally carrying a time part — `2026-01-15`, * `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse` (which also @@ -1391,9 +1417,8 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: {
{delta.direction === 'up' && } {delta.direction === 'down' && } @@ -1545,8 +1570,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { {delta.direction === 'up' && } diff --git a/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.polarity-26602.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.polarity-26602.test.tsx new file mode 100644 index 00000000000..834f09a70d6 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.polarity-26602.test.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26602 — a period-over-period delta is coloured by the measure's + * declared polarity. + * + * hotclm's "average approval turnaround" tile drew "↑ 23% vs previous period" + * in green for a turnaround that got 23% longer: the tile coloured every rise + * green. The dataset measure now declares `polarity`, the server relays it on + * the measure column's descriptor (`fields[].polarity`), and the delta's colour + * follows it: + * - `lower`: a rise is red, a fall is green; + * - `neutral`: no good/bad colour, the muted text colour; + * - `higher`: a rise is green, a fall is red; + * - undeclared: exactly today's colours (a rise green, a fall red), with no + * guess from the measure's name — the fixture's measure is literally named + * `turnaround_days`, and still reads as `higher` when it declares nothing. + * + * The arrow is the number's direction under every polarity. Both surfaces that + * colour a delta are pinned: the metric tile and the cross-tab cell. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { DatasetWidget } from '../DatasetWidget'; + +afterEach(cleanup); + +const Q2 = { submitted_on: { $gte: '2026-04-01', $lte: '2026-06-30' } }; +const GOOD = /text-emerald-600/; +const BAD = /text-rose-600/; + +type Polarity = 'higher' | 'lower' | 'neutral' | undefined; + +const field = (polarity: Polarity) => ({ + name: 'turnaround_days', type: 'number', label: 'Avg. turnaround', + ...(polarity ? { polarity } : {}), +}); + +/** The tile's delta badge, for a current/previous pair over a measure of `polarity`. */ +async function tileBadge(current: number, previous: number, polarity: Polarity) { + render( + ({ + rows: [{ turnaround_days: current, turnaround_days__compare: previous }], + fields: [field(polarity), { ...field(polarity), name: 'turnaround_days__compare' }], + })), + }} + />, + ); + return (await screen.findByTestId('dataset-compare-trend')).firstElementChild as HTMLElement; +} + +const arrow = (badge: HTMLElement) => badge.querySelector('svg')?.getAttribute('class') ?? ''; + +describe('DatasetWidget metric tile — delta colour by declared polarity (objectstack#26602)', () => { + it('lower: a rise is drawn bad, with the up arrow kept', async () => { + const badge = await tileBadge(123, 100, 'lower'); + expect(badge.textContent).toBe('23%'); + expect(badge.className).toMatch(BAD); + expect(badge.className).not.toMatch(GOOD); + expect(arrow(badge)).toMatch(/arrow-up/); + }); + + it('lower: a fall is drawn good, with the down arrow kept', async () => { + const badge = await tileBadge(80, 100, 'lower'); + expect(badge.className).toMatch(GOOD); + expect(badge.className).not.toMatch(BAD); + expect(arrow(badge)).toMatch(/arrow-down/); + }); + + it('neutral: neither a rise nor a fall takes a good/bad colour', async () => { + for (const [current, direction] of [[123, /arrow-up/], [80, /arrow-down/]] as const) { + const badge = await tileBadge(current, 100, 'neutral'); + expect(badge.className).not.toMatch(GOOD); + expect(badge.className).not.toMatch(BAD); + expect(badge.className).toMatch(/text-muted-foreground/); + expect(arrow(badge)).toMatch(direction); + cleanup(); + } + }); + + it('higher: a rise is good and a fall is bad', async () => { + expect((await tileBadge(123, 100, 'higher')).className).toMatch(GOOD); + cleanup(); + expect((await tileBadge(80, 100, 'higher')).className).toMatch(BAD); + }); + + it('undeclared: today\'s colours exactly, whatever the measure is named', async () => { + const up = await tileBadge(123, 100, undefined); + expect(up.className).toBe('flex shrink-0 items-center font-medium text-emerald-600 dark:text-emerald-400'); + cleanup(); + const down = await tileBadge(80, 100, undefined); + expect(down.className).toBe('flex shrink-0 items-center font-medium text-rose-600 dark:text-rose-400'); + }); + + it('a flat delta stays muted under every polarity', async () => { + for (const polarity of ['higher', 'lower', 'neutral', undefined] as const) { + const badge = await tileBadge(100, 100, polarity); + expect(badge.textContent).toBe('0%'); + expect(badge.className, String(polarity)).toBe('flex shrink-0 items-center font-medium text-muted-foreground'); + cleanup(); + } + }); +}); + +describe('DatasetWidget cross-tab cell — delta colour by declared polarity (objectstack#26602)', () => { + async function cellBadges(polarity: Polarity) { + render( + ({ + rows: [ + { team: 'Legal', kind: 'NDA', turnaround_days: 12, turnaround_days__compare: 10 }, + { team: 'Legal', kind: 'MSA', turnaround_days: 8, turnaround_days__compare: 10 }, + ], + fields: [ + { name: 'team', type: 'string', label: 'Team' }, + { name: 'kind', type: 'string', label: 'Kind' }, + field(polarity), + { ...field(polarity), name: 'turnaround_days__compare' }, + ], + })), + }} + />, + ); + const m = await screen.findByTestId('dataset-matrix'); + const stacks = within(m).getAllByTestId('matrix-cell-compare'); + const byDirection = (d: string) => + stacks.filter((s) => s.getAttribute('data-direction') === d).map((s) => s.lastElementChild as HTMLElement); + return { up: byDirection('up'), down: byDirection('down') }; + } + + it('lower: the rising cell is bad and the falling cell is good', async () => { + const { up, down } = await cellBadges('lower'); + expect(up.length).toBeGreaterThan(0); + expect(down.length).toBeGreaterThan(0); + for (const b of up) expect(b.className).toMatch(BAD); + for (const b of down) expect(b.className).toMatch(GOOD); + }); + + it('neutral: no cell takes a good/bad colour', async () => { + const { up, down } = await cellBadges('neutral'); + for (const b of [...up, ...down]) { + expect(b.className).not.toMatch(GOOD); + expect(b.className).not.toMatch(BAD); + } + }); + + it('undeclared: a rise green and a fall red, as before', async () => { + const { up, down } = await cellBadges(undefined); + for (const b of up) expect(b.className).toMatch(GOOD); + for (const b of down) expect(b.className).toMatch(BAD); + }); +});