Skip to content

Dashboard '0.0%' format: a ratio of exactly 1 renders as 1.0% instead of 100.0% #3136

Description

@baozhoutao

Summary

A dataset measure declared with format: '0.0%' renders correctly for every ratio below 1, but not for a ratio of exactly 1. 1 (i.e. 100 %) is printed as 1.0% instead of 100.0%. Both the dashboard metric card and the dataset-bound table widget are affected.

For an SLA / pass-rate / completion-rate dashboard this is the single worst value to get wrong: "everything met the SLA" is shown to management as "1 % met the SLA".

Repro

Dataset (ObjectStack app metadata):

measures: [
  { name: 'sla_base_count', label: 'SLA applicable', aggregate: 'count' },
  { name: 'met_count',      label: 'SLA met',        aggregate: 'count', filter: { sla_met: true } },
  { name: 'sla_rate',       label: 'SLA rate',
    derived: { op: 'ratio', of: ['met_count', 'sla_base_count'] }, format: '0.0%' },
]

Dashboard widgets: one type: 'metric' bound to sla_rate, one type: 'table' with dimensions: ['status'], values: ['total_count','sla_rate', …].

records in the base object ratio value metric card shows table cell shows expected
2 of 2 met 1 1.0% ❌ 1.0% ❌ 100.0%
2 of 3 met 0.6667 66.7% ✅ 66.7% ✅ 66.7%
1 of 3 escalated 0.3333 33.3% ✅ — 33.3%
0 of 2 escalated 0 0.0% ✅ — (blank, see note) 0.0%

Same dataset, same format string, same dashboard — only the value differs. Verified on a clean database, @objectstack/*@17.0.0-rc.1, Console dashboard, Chromium.

Cause

The percent path scales by 100 only when the value is strictly inside (-1, 1), so a ratio of exactly 1 falls through unscaled and then gets a % appended. From the shipped console bundle (assets/ui-components-*.js):

o = a ? i : (i > -1 && i < 1 ? i * 100 : i)

and the sibling implementation in assets/plugin-dashboard-*.js carries the same "already a percentage?" heuristic:

return o ? `${(e > 1 ? e : e * 100).toFixed(c)}%` : …

The heuristic is trying to accept both scales (0–1 ratios and 0–100 percentage points) on one format string. That is inherently ambiguous exactly at 1, which is both "100 %" and "1 %". Today the ambiguity is resolved in favour of the reading that is almost never intended — a ratio measure can legitimately reach 1 (full compliance), while a percentage-point measure of 1 (one percent) is rare, and sla_rate in the example above can only ever be in [0, 1].

Suggestion

Make the scale explicit rather than guessed — the declaration already carries enough information to know which one it is (derived: { op: 'ratio' } is by definition 0–1). Options, roughly in order of preference:

  1. Honour the ratio semantics: a % format always multiplies by 100 (numeral.js / Excel convention, which '0.0%' is borrowed from). Anything already in percentage points would then declare '0.0' + a literal % suffix, or a new explicit format.
  2. Keep the heuristic but let the author override it — e.g. scale: 'ratio' | 'percent' on the measure.
  3. At minimum, scale on i <= 1 rather than i < 1, so full compliance renders as 100.0%. (Narrow fix; still ambiguous for percentage-point data whose value is ≤ 1.)

Related, same widget family

In the dataset-bound table widget a 0 ratio renders as — (empty), while the metric card renders 0.0%. A measured zero and "no data" are different facts; the table treating 0 as blank hides real information (e.g. "0 % of this status met the SLA").

Activity

  1. self-assigned this
    on Aug 1, 2026
  2. baozhoutao commented on Aug 1, 2026

    @baozhoutao
    ContributorAuthor

    根因确认了,比 issue 里推断的更靠上一层,两处都修了。

    不是猜错了,是没告诉前端

    % 格式串只说了怎么打印,没说这个数在什么刻度上——而这两种读法在恰好 1 处必然撞车:它同时是「100%」(0–1 比率满分)和「1%」(一个百分点)。渲染层手里只有一个裸数字加一个 '0.0%',于是靠数值大小猜,猜成了几乎没人想要的那个。

    但刻度从来不是不可知的,只是没离开服务端:

    • derived: { op: 'ratio' } 按定义就是 0–1;
    • 聚合 percent 字段的度量,继承那个字段的存储刻度。

    这两条都在服务端度量列富化那一段已经读过的元数据里——货币链(ADR-0053)就走在同一段代码里:回查源字段、确认 type === 'currency'、把币种挂到结果列上。百分比没有这个待遇。现在有了,走同一个 seam。

    所以 issue 里的三个选项:方案 1 会波及 list-view 和 grid 汇总(formatMeasure(50, '0%') 会变 5000%);方案 3 修好这一个值、把猜法原样留着等下一个。猜法本身才是缺陷,接近方案 2,但作者不用多写一个字——信号本来就在。

    落地

    服务端 objectstack-ai/objectstack#4442

    • percentScaleOf(field):唯一判定点。percent 字段默认存小数,声明 max > 1(如 min: 0, max: 100)才是整数百分点——这不是新发明的约定,是百分比编辑控件本来就照着写入的规则,读侧照它读才能 round-trip。非 percent 字段不发表意见。
    • AnalyticsResult.fields[].percentScale:'fraction' | 'whole',非百分比列不带。顺带把 ADR-0053 以来一直靠 cast 偷偷写出去的 currency 补进了接口声明。
    • measureCurrency → sourceFieldMeta(返回值加 max)。这个 seam 早就不只管货币了:日期分桶靠它读 type 区分 date/datetime,百分比是第三个消费者。

    Console #3140 — formatMeasure 收下声明的刻度并按它缩放,12 个 dataset 调用点全部传上。percentDisplayValue 一字未动,仍是没有注解时的兜底,今天渲染正确的东西一个都不变。

    关于文末那条 related

    也修了,而且根因不在 formatMeasure——它对 0 返回的就是 0.0%。是服务端根本没返回这一行:带 filter 的 count 在没有匹配行的分组里,数据库直接省略该分组,合并后就和「没测量」分不清了。

    但对 count/sum 来说那是测量出来的 0。emptyGroupValueFor(aggregate) 声明哪些聚合在空集上有单位元,执行器在所有补充查询合并之后统一填(后面的度量合并可能追加前面没见过的维度键)。avg/min/max 保持 null——空集没有平均值可言,填 0 等于凭空捏造一个测量值。

    顺带说明,那两个部件的分歧还有一半在 Console:KPI 卡的 ?? 0 会把缺失值伪造成 0,表格却显示 —。这个没动——有两条既有测试把它钉成刻意选择(「空数据集的 metric 显示 0 而不是 No rows」),而且服务端开始如实返回 0 之后,两个面在所有测量到的值上已经一致了。要改得单独讨论。

    实测

    真实后端(objectstack dev --fresh + showcase 种子),POST /api/v1/analytics/dataset/query:

    {"status": "Paid", "invoice_count": 5, "paid_count": 5, "paid_rate": 1}
    {"status": "Sent", "invoice_count": 5, "paid_count": 0, "paid_rate": 0}
    {"name": "paid_rate", "format": "0.0%", "percentScale": "fraction"}

    浏览器(Console dev server 指向该后端,Revenue Pulse 仪表盘)——同一个跑着的界面里做 A/B,拦截响应剥掉注解即可还原修复前的服务端:

    Paid Rate(Paid 行,值恰好为 1)
    带注解 100.0% ✅
    剥掉 percentScale 1.0% ❌ ← 本 issue
    恢复 100.0% ✅

    Sent 行从 — / — 变成 0 / 0.0%。

    showcase 里加了 paid_rate 比率度量 + KPI 卡和分组表:按 status 分组时 Paid 桶恒等于 1,复现值从此是被演示的,不是被描述的。

    测试:service-analytics 470 通过(新增 5 条)、spec/data 1234 通过、objectui 侧涉及面 30 文件 254 通过(新增用例断言值=1 时 KPI 卡和表格都渲染 100.0%)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions