Repository navigation
Commit c26d643
feat(spec): a dataset measure declares its polarity, so a lower-is-better KPI draws a rising delta as bad news (#26739)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBSoRovYVnrLPiM3xeEDVWqq)_
Fixes #26602
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: updated here — skills/objectstack-ui/rules/dashboards.md
(the metric-widget `compareTo` bullet and the measure presentation
line), skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
(eval 8 now asks for the polarity and asserts `polarity: 'lower'`),
content/docs/ui/dashboards.mdx
## 维护者速读
- 改了什么:数据集度量新增可选键 `polarity`(`higher` / `lower` /
`neutral`),分析服务把它带到答案的列描述上,仪表盘 KPI 卡片和交叉表单元格按它给环比变化上色。
- 为什么改:「平均审批周转」这类越低越好的指标,周转变慢 23% 却显示绿色上升箭头,把坏消息画成了好消息。
- 风险与回滚:未声明 `polarity` 的度量颜色与现在完全一致(上升绿、下降红),纯新增;回滚即还原本 PR。
-
先看:`packages/spec/src/ui/dataset.zod.ts`(新键与取值)、`ui/packages/plugin-dashboard/src/DatasetWidget.tsx`
的
`deltaToneClass`、`packages/services/service-analytics/src/analytics-service.ts`
的 `enrichResultColumns` 一行转发。
- 两分钟验证:在 `ui/` 目录执行 `pnpm exec vitest run
packages/plugin-dashboard/src/__tests__/DatasetWidget.polarity-26602.test.tsx`,预期
9 个用例全部通过。
- 需要你做的:本 PR 触及 `skills/**`(Tier H),合并需要你的批准;另请确认 eval 文件 token 上限按裁决 A
抬高 13。
## What changes
**Where the key lives, and why.** The card offered two seats: the
widget's `compareTo` or the dataset measure. Measured at `origin/main`
4373103, the widget's `compareTo` cannot carry it:
- `DatasetWidget` forwards `widget.compareTo` verbatim into the dataset
selection.
- The wire `DatasetCompareToSchema`
(`packages/spec/src/api/analytics.zod.ts`) is a `strictObject`, so a new
member there would be refused on every query.
- `packages/spec/src/ui/dashboard-compareto.test.ts` pins "a parsed
widget `compareTo` is assignable to `DatasetCompareTo` with no mapping".
The widget's slot is a thin projection of the executor's own directive,
by design.
So the key is declared on the **dataset measure only**, the semantic
layer, where 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. A widget-level
override would be a later, pure widening if a consumer needs one.
**The values.** `DatasetMeasurePolarity = z.enum(['higher', 'lower',
'neutral'])`. They name the better direction rather than a sign or a
boolean, so `neutral` (no good/bad colour, for a measure where neither
direction is news) is a peer value and not a third state of a flag.
Undeclared keeps today's colours exactly (a rise green, a fall red).
Nothing infers a polarity from the measure's name or unit.
- **Spec** (`packages/spec/src/ui/dataset.zod.ts`):
`DatasetMeasureSchema.polarity`, optional, no default materialized.
Near-miss keys `betterWhen` (the card's own proposal), `direction`,
`goodDirection`, `trendDirection` are pointed at `polarity`; the boolean
spellings `higherIsBetter` / `lowerIsBetter` are answered with the three
values.
- **Wire** (`packages/spec/src/api/analytics.zod.ts`,
`packages/spec/src/contracts/analytics-service.ts`): the column
descriptor declares `fields[].polarity` with the same enum, mirrored in
`AnalyticsResult` and `AnalyticsResultResponseSchema` (the existing
compile-time drift guard binds the two).
- **Producer**
(`packages/services/service-analytics/src/analytics-service.ts#enrichResultColumns`):
one line relays the authored value, on the live query and the draft
preview alike, the `__compare` column included. Undeclared measures and
dimension columns carry no key, so an answer over a dataset that
declares none is byte-identical.
- **Renderer** (`ui/packages/plugin-dashboard/src/DatasetWidget.tsx`):
one helper, `deltaToneClass(direction, polarity)`, colours both surfaces
that colour a delta, the metric tile and the cross-tab cell. `lower`
draws a rise red and a fall green; `neutral` draws neither (the tile's
muted text colour); `higher` and undeclared draw a rise green and a fall
red. The arrow keeps the number's direction under every polarity, and a
flat delta stays muted.
- **Metadata form** (`packages/spec/src/ui/dataset.form.ts`): the
measures repeater offers `polarity`, labelled in `zh-CN` / `ja-JP` /
`es-ES`
(`packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts`).
- **Liveness** (`packages/spec/liveness/dataset.json`):
`measures.polarity` live, with the renderer as evidence and
`enrichResultColumns` as producer.
- **Skills and docs**: the metric-widget bullet in
`skills/objectstack-ui/rules/dashboards.md` teaches the key (paid for
inside the file, and the file's ceiling is lowered 6239 → 6221); eval 8
asks for it; `content/docs/ui/dashboards.mdx` gains a presentation-home
row and a sentence on the `compareTo` row.
- **Changesets**: `.changeset/26602-measure-polarity.md`
(`@objectstack/spec` minor, `@objectstack/service-analytics` minor,
`@objectstack/platform-objects` minor for the four regenerated
`metadata-forms` translation catalogs it publishes under `./apps`),
`.changeset/26602-delta-polarity.md` (`@object-ui/plugin-dashboard`
minor; ui changesets live in the root directory since #25745).
## Why
A KPI's colour is read before its number. hotclm's average approval
turnaround tile showed "↑ 23% vs previous period" in green for a
turnaround that got 23% longer.
## Tests
All at head `aef918b95`, on `origin/main` 4373103 (main has not moved
since the branch was cut).
- `packages/spec/src/ui/dataset-measure-polarity.test.ts` (17): the
three values parse and keep their value; the closed vocabulary; an
absent key materializes nothing; `up` / `down` / `good` / `inverse` /
`true` refused at the key (`invalid_value` at `['polarity']`);
`betterWhen` and `direction` pointed at `polarity`; the boolean
spellings answered with the values; the wire descriptor accepts exactly
the measure's vocabulary and stays optional.
-
`packages/services/service-analytics/src/__tests__/measure-column-polarity.test.ts`
(3), each on the live path and the draft preview: each declared value
relayed as written; no key on an undeclared measure or a dimension
column; the `__compare` column carries its measure's polarity.
-
`ui/packages/plugin-dashboard/src/__tests__/DatasetWidget.polarity-26602.test.tsx`
(9): the metric tile for `lower` (rise bad, fall good, arrows kept),
`neutral` (no tone, muted), `higher`, undeclared (class string
byte-identical to today's), and a flat delta muted under every polarity;
the cross-tab cell for `lower`, `neutral` and undeclared.
- Reverse verification, through `scripts/ablation-replace.mjs`, each
restored to `HEAD` by blob hash: deleting the relay line in
`enrichResultColumns` turns 2 of 3 service tests red (the two positive
ones; the absence test stays green); replacing `deltaToneClass` with the
old direction-only colouring turns 4 of 9 renderer tests red (both tile
`lower` cases, the cell `lower` and the cell `neutral` cases).
- Full suites: `@objectstack/spec` Test Files 732 passed (732), Tests
21938 passed, 1 todo. `@objectstack/service-analytics` Test Files 192
passed (192), Tests 4650 passed, 282 skipped.
`@objectstack/platform-objects` Test Files 96 passed (96), Tests 1361
passed. `@object-ui/plugin-dashboard` Test Files 190 passed (190), Tests
1864 passed, 6 skipped.
- Typecheck: `@objectstack/spec`, `@objectstack/service-analytics`,
`@objectstack/platform-objects` exit 0. `@object-ui/plugin-dashboard`
type-check NOT MEASURED locally: it exits 2 with 133 errors, every one a
`Cannot find module '@object-ui/*'` or an implicit `any` downstream of
one, because the sibling `@object-ui/*` packages have no built
declarations in this container. CI's `Type Check UI` is the reading.
## Acceptance notes
- **Skills token ceiling raised once, under a standing ruling.**
`skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` grows +29
tokens for the polarity in eval 8, part-funded by trimming two
parentheticals, eval 2's note that restated its own expected output and
eval 8's "(a rise draws red)". The file is at 5443 bytes. The balance
(+13, 1348 → 1361) is raised under the maintainer ruling recorded beside
that row in `scripts/check-skills-token-ratchet.mjs`, quoted verbatim:
「A:先削重复,余额按实测提高」. Seat: please confirm that ruling covers this eval; the
alternative is dropping the eval change and declaring the skills half
`follow-up owed`.
- Skills readings (lines): `rules/dashboards.md` 464 → 462;
`evals/analytics-inline-vs-dataset.json` 87 → 87 (5389 → 5462 bytes);
all `SKILL.md` 4600 → 4600; whole `skills/` tree 19192 → 19190.
- On the metric tile, `cn` is tailwind-merge, so the tile's explicit
muted class for `neutral` would win over a tone class even without the
helper's `neutral` branch. The cross-tab cell has no such class, which
is why its `neutral` pin is the one that goes red when the helper's
branch is removed.
- Not carried (noted, not filed): the inline `ObjectMetricWidget` /
`MetricWidget` trend path reads no dataset measure, so it has no
polarity to read; `DatasetReportRenderer` draws no comparison delta.
## Verification
- `pnpm --filter @objectstack/spec check:generated --fix` regenerated
the 5 stale artifacts (api-surface, export-origins, declaration-map,
references docs, liveness counts) from a rebuilt dist and re-checked
them green; the other 9 were already current.
- `pnpm --filter @objectstack/spec check:empty-state`: all classified
(no new permissive empty-state statement).
- `pnpm check:i18n`: OK, 10 packages in sync. The dataset form's
measures repeater offers `polarity` (the spec's form/Zod reconciliation
pin required it), labelled 极性 / 極性 / Polaridad; the two pinned
populations move by one (`object-lifecycle-panel-echo-decisions`
positive control 689 → 690 on today's `main`,
`report-form-echo-decisions` dataset leaves 39 → 40).
- `node scripts/check-skills-token-ratchet.mjs` and `--self-test`: OK
(79 cases).
- eslint on every changed source file: 0 errors.
- The derived gate families (`node scripts/pm/dispatch-gates.mjs
--commands`, 138 at this head) are running locally; their verdicts go in
the os-dev report on #26602, and the PR's check-runs are the CI reading.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed
files; the repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfjMN88NYp5BcCRyGR
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzfjMN88NYp5BcCRyGR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2205863 commit c26d643
33 files changed
Lines changed: 624 additions & 38 deletions
File tree
- .changeset
- content/docs
- api
- data-modeling
- references
- ui
- ui
- packages
- platform-objects/src/apps/translations
- services/service-analytics/src
- __tests__
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- api
- contracts
- ui
- scripts
- skills/objectstack-ui
- evals
- rules
- ui/packages/plugin-dashboard/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
474 | 474 | | |
475 | 475 | | |
476 | 476 | | |
477 | | - | |
| 477 | + | |
| 478 | + | |
478 | 479 | | |
479 | 480 | | |
480 | 481 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
263 | 267 | | |
264 | 268 | | |
265 | 269 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
369 | 369 | | |
370 | 370 | | |
371 | 371 | | |
372 | | - | |
| 372 | + | |
373 | 373 | | |
374 | 374 | | |
375 | 375 | | |
| |||
382 | 382 | | |
383 | 383 | | |
384 | 384 | | |
385 | | - | |
| 385 | + | |
386 | 386 | | |
387 | 387 | | |
388 | 388 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
38 | | - | |
| 37 | + | |
| 38 | + | |
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| 91 | + | |
91 | 92 | | |
92 | 93 | | |
93 | 94 | | |
| |||
143 | 144 | | |
144 | 145 | | |
145 | 146 | | |
| 147 | + | |
146 | 148 | | |
147 | 149 | | |
148 | 150 | | |
| |||
168 | 170 | | |
169 | 171 | | |
170 | 172 | | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
171 | 184 | | |
172 | 185 | | |
173 | 186 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
| 106 | + | |
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
| |||
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| 150 | + | |
150 | 151 | | |
151 | 152 | | |
152 | 153 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2095 | 2095 | | |
2096 | 2096 | | |
2097 | 2097 | | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
2098 | 2101 | | |
2099 | 2102 | | |
2100 | 2103 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2095 | 2095 | | |
2096 | 2096 | | |
2097 | 2097 | | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
2098 | 2101 | | |
2099 | 2102 | | |
2100 | 2103 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2095 | 2095 | | |
2096 | 2096 | | |
2097 | 2097 | | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
2098 | 2101 | | |
2099 | 2102 | | |
2100 | 2103 | | |
| |||
0 commit comments