Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/26602-delta-polarity.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions .changeset/26602-measure-polarity.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,8 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
<Callout type="info">
**`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,
Expand Down
4 changes: 4 additions & 0 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.

Expand All @@ -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` |
Expand Down
19 changes: 16 additions & 3 deletions content/docs/references/ui/dataset.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`. |

Expand Down Expand Up @@ -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`. |

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,9 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
"measures.currency": {
label: "Currency"
},
"measures.polarity": {
label: "Polarity"
},
"measures.derived": {
label: "Derived From"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,9 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
"measures.currency": {
label: "Moneda"
},
"measures.polarity": {
label: "Polaridad"
},
"measures.derived": {
label: "Derivada de"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,9 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
"measures.currency": {
label: "通貨"
},
"measures.polarity": {
label: "極性"
},
"measures.derived": {
label: "派生元"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,9 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
"measures.currency": {
label: "货币"
},
"measures.polarity": {
label: "极性"
},
"measures.derived": {
label: "派生自"
},
Expand Down
Loading
Loading