Skip to content

Commit 4df174b

Browse files
fix(app-showcase): showcase money reads one way — roll-up scale, USD project amounts, one total-budget format (#25543)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Fixes #25504 Clause-②: no Metadata-protocol: none ## What changes Showcase money now reads one way. Every change is a metadata declaration in `examples/app-showcase`. Nothing in `packages/spec` or objectui is edited. | Reading on `main` | Declaration | Spec key | |:---|:---|:---| | Expense Reports → Total reads `225.75`, `917`, `8,900`, `1,500.5`. Approved and Reimbursable have the same problem. | `scale: 2` on the `total_amount`, `approved_amount` and `reimbursable_amount` roll-ups | `FieldSchema.scale`, `packages/spec/src/data/field.zod.ts:1353`. The superRefine at `field.zod.ts:2836` refuses it only on `type: 'currency'`, so it is valid on a `summary`. The renderer reads it: the `@object-ui/fields` 18.0.0 changelog entry for objectui#11752 says "A `scale` on the field fixes the width in both faces (`scale: 2` reads `15,750.50`)". | | Projects → Budget reads `200,000.00` with no currency. Accounts → Annual Revenue reads `US$12,000,000.00`. | `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` on `budget`, and on `spent` (see below). This is the exact value on `showcase_account.annual_revenue`. | `FieldSchema.currencyConfig`, `field.zod.ts:1940`, which is `CurrencyConfigSchema` (`field.zod.ts:546`). `defaultCurrency` is read only under `currencyMode: 'fixed'` (`field.zod.ts:552`). | | Projects → Budget Remaining reads `200,000`. | `returnType: 'currency'`, `currencyConfig` USD fixed and `scale: 2` on the `budget_remaining` formula | `FieldSchema.returnType`, `field.zod.ts:1880`, where `'currency'` landed with #22727 (commit e865300). The formula's own `currencyConfig` (`field.zod.ts:1940`) names its currency. `scale` on a formula rounds the computed result (`field.zod.ts:1353` describe; `roundFormulaValue` in `packages/objectql/src/engine.ts`). | | The Command Center KPI reads `109万`, and Delivery Operations → Total Budget reads `1,090,000`, for the same sum. | Both use format `0,0.00` and currency USD | `object-metric`: `ObjectMetricPropsSchema.format` / `.currency`, `packages/spec/src/ui/component.zod.ts:5774-5775`. Dataset-bound tile: `DatasetMeasureSchema.format` / `.currency`, `packages/spec/src/ui/dataset.zod.ts:308` and `:320`. | ### The KPI format: two decimals with currency, on both Today `kpi()` in `command-center.page.ts` spreads its `format` argument into the `object-metric` properties, and `cc_k6` passes `'0.0a'`. `kpi()` now also takes an optional `currency`. `cc_k6` passes `BUDGET_FORMAT = '0,0.00'` and `BUDGET_CURRENCY = 'USD'`. I picked two decimals with currency over a compact format for two reasons: - **It matches the money columns.** After this change, the Projects list writes an amount as `US$200,000.00`. A total budget in the same shape reads as the same kind of number. - **A compact format cannot render the same on both tiles.** I read the 18.0.0 npm builds of `@object-ui/plugin-dashboard` and `@object-ui/core`: - The `object-metric` tile formats through `MetricWidget`. It honours a numeral `a` as `Intl` compact notation, with or without a currency. - The dataset-bound tile formats through `formatMeasure` (`@object-ui/core`, `utils/dataset-format.js`). With a currency, it counts the zeros after the dot as fraction digits and never applies compact notation. - So `0.0a` would read `US$109万` on one dashboard and `US$1,090,000.0` on the other. With `0,0.00`, both tiles produce `US$1,090,000.00` in zh-CN. The tile layout is built for a value that long. The `metricTile.d.ts` docblock says "a seven-digit money figure with cents — takes a size ladder from 14 px to 24 px". The `object-metric` tile reads no currency from the field: the 18.0.0 `plugin-dashboard` build contains no `currencyConfig` or `defaultCurrency` read. So `cc_k6` must name the currency itself. The dataset-bound tile gets its currency from the server's column annotation: the measure's `currency`, then the field's fixed `currencyConfig.defaultCurrency` (`service-analytics` `plugin.ts`, the #20091 block). `budget_sum` therefore needs no `currency` of its own now that `budget` declares USD. **Landing note.** The claim names "the Delivery Operations dashboard" for the second KPI's format. Its `kpi_total_budget` widget has no format key of its own. `DashboardWidget.options` declares none (`packages/spec/src/ui/dashboard.zod.ts`, the widget-options docblock: "a number's face is the dataset MEASURE's `format` + `currency`"). So the format lives in the producer, `src/ui/datasets/chart-gallery.dataset.ts`: - `budget_sum` changes from `0,0` to `0,0.00`. - `spent_sum` gets the same change, because the "Budget vs Spent by Account" tables print the two measures side by side. - The comment over the measures said `budget`/`spent` had no fixed currency. I rewrote it to match. ### In-place addition: `spent` declares USD too The claim names `currencyConfig` on `budget` only. I also declared it on `spent`, in the same file. A currency result on `budget_remaining` is only honest if both amounts it subtracts are in one currency. `inferFormulaReturn` (`packages/formula/src/formula-return.ts`) answers "two different currencies meeting … is unproven". With USD `budget` and dynamic `spent`, it would stamp `returnType: 'number'` for this expression, not `'currency'`. With both in USD, it stamps exactly the pair this PR declares, and the pin asserts that. This also keeps the Projects list to one currency treatment, as the issue's Expected section asks. It meets the bounded in-place criteria: the same defect class, a mechanical declaration, a file inside the claimed surface, and no new gate family. ## Tests The new pin is `examples/app-showcase/test/money-format-declarations.test.ts`. It follows the existing showcase style: it reads the authored metadata and walks the page tree through the helpers. Its five cases assert: 1. `scale: 2` on the three money roll-ups. 2. `budget` and `spent` carry the same `currencyConfig` as `showcase_account.annual_revenue`. 3. `budget_remaining` is `returnType: 'currency'` with USD fixed and `scale: 2`. 4. That result equals what `inferFormulaReturn` stamps for its expression over the object's fields. 5. `cc_k6`'s `format` equals `budget_sum`'s `format`, and `cc_k6`'s `currency` equals the currency the dataset tile resolves: the measure's, else the field's fixed one. **Ablation.** I ran `scripts/ablation-replace.mjs` on the committed fix (2a70334), with an EXIT/INT/TERM trap restore. Each leg's anchor hit as declared, the blob changed, and the restore was proven by blob == HEAD and an empty `git diff HEAD`: | Leg | Mutation | Result | |:---|:---|:---| | A | delete the three roll-up `scale: 2` lines (anchor x3 → x0) | `Tests 1 failed / 4 passed` | | B | drop `budget`'s `currencyConfig` (x1 → x0) | `Tests 3 failed / 2 passed` (cases 2, 4 and 5) | | C | drop the formula's `returnType: 'currency'` | `Tests 2 failed / 3 passed` (cases 3 and 4) | | D | `BUDGET_FORMAT` changed to `'0.0a'` | `Tests 1 failed / 4 passed` (case 5) | After all four legs, the three mutated blobs equal HEAD and `git status --porcelain` is empty. **Runs on the final head 5afdc0d** (`origin/main` 5ea211c merged in). Every run is under `os-verify-lock`, and each VERDICT is `command-exit 0`: - `pnpm --filter @objectstack/example-showcase typecheck`: exit 0. `tsc --listFiles` includes the new test. - `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2`: `Test Files 39 passed (39)`, `Tests 483 passed (483)`. - `pnpm --filter @objectstack/example-showcase validate` (`os validate`): `✓ Validation passed`, 64 warnings. - The sorted warning set is identical to the same validate run with the four source files restored to the base (8ef2a96) declarations, so the change adds no warning. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` on 5afdc0d derived 46 commands. I ran each one and captured its exit code before any pipe: - 45 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3, `PREREQUISITE NOT MET`. It needs a whole-workspace `dist/`, and this worktree built only the showcase closure. It is NOT MEASURED here; CI's `Lint & Repo Gates` runs it. `dispatch-gates --ran`, with the recorded exit codes, reconciles to `46 derived famil(ies) accounted for — 45 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. Changeset: none, label `skip-changeset`. `@objectstack/example-showcase` is `private: true` and publishes nothing. ## Acceptance notes - **A roll-up over a currency field has no spec channel to say it is money.** `FieldSchema.returnType` is described as the value type "of a formula field" (`field.zod.ts:1880`). objectui's `SummaryField` docblock (18.0.0) says "A summary declares no `returnType`". `formula-return.ts` notes that "a roll-up of a currency field is itself money". - So the expense roll-ups can declare a width (`scale: 2` reads `1,500.50`) but no currency. - That is why the expense line `amount` is left without a `currencyConfig`. Declaring USD there would put `US$` on the lines and a bare number on the roll-ups, which recreates the four-ways defect inside one object. - The Expense Reports screens are now self-consistent, with two decimals and no symbol. The Accounts and Projects screens read in USD. - Carrier: none. - **Other showcase money measures are untouched.** `subtotal_sum` and `revenue_sum` (`revenue-pulse.dataset.ts`, `0,0`) are outside this card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5eab06 commit 4df174b

5 files changed

Lines changed: 168 additions & 15 deletions

File tree

‎examples/app-showcase/src/data/objects/expense-report.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,22 @@ export const ExpenseReport = ObjectSchema.create({
6969
// ── Filtered roll-ups (the point of this object) ─────────────────────────
7070
// Child FK is auto-detected (showcase_expense_line.expense_report), so none
7171
// of these need `relationshipField`.
72+
//
73+
// The three money roll-ups declare `scale: 2`. A summary has no currency
74+
// of its own, and with no `scale` a sum renders at the natural precision of
75+
// its value, so one column read `225.75`, `917` and `1,500.5`. Two
76+
// decimals match the line `amount` they add up.
7277

7378
/** Baseline: unfiltered — the grand total of every line. */
7479
total_amount: Field.summary({
7580
label: 'Total',
81+
scale: 2,
7682
summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'sum' },
7783
}),
7884
/** Filtered SUM (equality) — only lines a manager has approved. */
7985
approved_amount: Field.summary({
8086
label: 'Approved',
87+
scale: 2,
8188
summaryOperations: {
8289
object: 'showcase_expense_line',
8390
field: 'amount',
@@ -88,6 +95,7 @@ export const ExpenseReport = ObjectSchema.create({
8895
/** Filtered SUM (boolean) — only lines billable back to a client. */
8996
reimbursable_amount: Field.summary({
9097
label: 'Reimbursable',
98+
scale: 2,
9199
summaryOperations: {
92100
object: 'showcase_expense_line',
93101
field: 'amount',

‎examples/app-showcase/src/data/objects/project.object.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,33 @@ export const Project = ObjectSchema.create({
6060
{ label: 'Red', value: 'red', color: '#EF4444' },
6161
],
6262
}),
63-
budget: Field.currency({ label: 'Budget', min: 0 }),
64-
spent: Field.currency({ label: 'Spent', min: 0, defaultValue: 0 }),
63+
// Money in one fixed currency, as on `showcase_account.annual_revenue`: with
64+
// no currency code a currency field reads as a bare number (`200,000.00`)
65+
// beside that column's `US$12,000,000.00`. `spent` declares the same
66+
// currency as `budget`, so `budget_remaining` subtracts two amounts in one
67+
// currency.
68+
budget: Field.currency({
69+
label: 'Budget',
70+
min: 0,
71+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
72+
}),
73+
spent: Field.currency({
74+
label: 'Spent',
75+
min: 0,
76+
defaultValue: 0,
77+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
78+
}),
79+
// A money result (#22727): `returnType: 'currency'` with the formula's own
80+
// `currencyConfig` renders it as its sources render, not as a plain number
81+
// (`200,000`). This pair is what `inferFormulaReturn` stamps for this
82+
// expression over two USD amounts. `scale: 2` rounds the computed result
83+
// to cents, so a float subtraction never returns a long fraction.
6584
budget_remaining: Field.formula({
6685
label: 'Budget Remaining',
6786
expression: cel`(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)`,
87+
returnType: 'currency',
88+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
89+
scale: 2,
6890
}),
6991
start_date: Field.date({ label: 'Start Date' }),
7092
end_date: Field.date({ label: 'Target End Date' }),

‎examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,17 @@ export const ShowcaseProjectDataset = defineDataset({
5555
],
5656
measures: [
5757
{ name: 'project_count', label: 'Projects', aggregate: 'count' },
58-
// `budget`/`spent` are currency fields with NO fixed currency (no
59-
// `currencyConfig`, so no currency of their own). The column's currency
60-
// resolves through the measure's own `currency` → the field's
61-
// `currencyConfig.defaultCurrency`, read only under `currencyMode: 'fixed'`
62-
// → the tenant `localization.currency` default; with none set it shows a
63-
// plain grouped number (never a hardcoded "$"). To pin a symbol, set a
64-
// measure `currency`, or give the field a fixed currency:
65-
// `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`.
66-
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0' },
67-
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0' },
58+
// `budget`/`spent` are currency fields with a FIXED currency
59+
// (`currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`).
60+
// The column's currency resolves through the measure's own `currency` →
61+
// the field's `currencyConfig.defaultCurrency`, read only under
62+
// `currencyMode: 'fixed'` → the tenant `localization.currency` default, so
63+
// these measures need no `currency` of their own (never a hardcoded "$"
64+
// in `format`). `0,0.00` writes a total the way the Projects list writes
65+
// an amount, with two decimals. The Command Center's `cc_k6` tile, which
66+
// aggregates `budget` without this dataset, declares the same pattern and
67+
// currency, so both dashboards read one total budget alike.
68+
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0.00' },
69+
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0.00' },
6870
],
6971
});

‎examples/app-showcase/src/ui/pages/command-center.page.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,23 @@ function band(id: string, cols: number, children: any[], gap = '16px'): any {
7676
}
7777

7878
/** A LIVE KPI — object-metric in the new `bare` variant (big tinted number + label). */
79-
function kpi(id: string, object: string, label: string, colorVariant: string, aggregate: any, filter?: any, format?: string): any {
79+
function kpi(id: string, object: string, label: string, colorVariant: string, aggregate: any, filter?: any, format?: string, currency?: string): any {
8080
return {
8181
id, type: 'object-metric',
8282
responsiveStyles: { large: { minWidth: '0' } },
83-
properties: { objectName: object, label, colorVariant, variant: 'bare', aggregate, ...(filter ? { filter } : {}), ...(format ? { format } : {}) },
83+
properties: { objectName: object, label, colorVariant, variant: 'bare', aggregate, ...(filter ? { filter } : {}), ...(format ? { format } : {}), ...(currency ? { currency } : {}) },
8484
};
8585
}
8686

87+
/**
88+
* How a total budget is written: the pattern and currency of the
89+
* `showcase_project_metrics` dataset's `budget_sum` measure, which the
90+
* Delivery Operations "Total Budget" tile reads. `object-metric` takes no
91+
* currency from the field, so `cc_k6` names it.
92+
*/
93+
const BUDGET_FORMAT = '0,0.00';
94+
const BUDGET_CURRENCY = 'USD';
95+
8796
/**
8897
* A dataset-bound chart with the shared brand palette. `integerYAxis` pins the
8998
* tick step to 1 — without it, count aggregates (small integers like 0-5)
@@ -159,7 +168,7 @@ export const CommandCenterPage = definePage({
159168
kpi('cc_k3', 'showcase_task', '待复审 Review', 'purple', { field: 'id', function: 'count' }, IN_REVIEW),
160169
kpi('cc_k4', 'showcase_project', '风险项目 At-Risk', 'danger', { field: 'id', function: 'count' }, [{ field: 'health', operator: 'equals', value: 'red' }]),
161170
kpi('cc_k5', 'showcase_account', '客户 Accounts', 'orange', { field: 'id', function: 'count' }),
162-
kpi('cc_k6', 'showcase_project', '总预算 Budget', 'success', { field: 'budget', function: 'sum' }, undefined, '0.0a'),
171+
kpi('cc_k6', 'showcase_project', '总预算 Budget', 'success', { field: 'budget', function: 'sum' }, undefined, BUDGET_FORMAT, BUDGET_CURRENCY),
163172
], '10px'),
164173
}),
165174

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { inferFormulaReturn } from '@objectstack/formula';
5+
6+
import { Account, ExpenseReport, Project } from '../src/data/objects/index.js';
7+
import { ShowcaseProjectDataset } from '../src/ui/datasets/index.js';
8+
import { CommandCenterPage } from '../src/ui/pages/index.js';
9+
import { OpsDashboard } from '../src/ui/dashboards/index.js';
10+
11+
/**
12+
* The showcase writes money one way.
13+
*
14+
* Every declaration this pins is display metadata the renderer already reads,
15+
* so leaving one out raises nothing at `os validate` or at boot: the column
16+
* just reads differently from the one beside it. The four readings it guards
17+
* against:
18+
*
19+
* - the expense report roll-ups summed at their natural precision, so one
20+
* column read `225.75`, `917` and `1,500.5` — they declare `scale: 2`;
21+
* - `showcase_project.budget` had no currency and read `200,000.00` beside
22+
* Accounts' `US$12,000,000.00` — it declares the same fixed currency;
23+
* - `budget_remaining`, a formula over two amounts, read as a plain number
24+
* (`200,000`) — it declares a currency result;
25+
* - the Command Center KPI wrote the total budget as `109万` and Delivery
26+
* Operations wrote the same sum as `1,090,000` — both write it with one
27+
* pattern and one currency.
28+
*/
29+
30+
type AnyRec = Record<string, unknown>;
31+
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);
32+
33+
const fields = (o: unknown): Record<string, AnyRec> => (o as { fields: Record<string, AnyRec> }).fields;
34+
35+
const USD_FIXED = { currencyMode: 'fixed', defaultCurrency: 'USD' };
36+
37+
/** The node with this `id`, at any depth. */
38+
function nodeById(root: unknown, id: string): AnyRec | undefined {
39+
let found: AnyRec | undefined;
40+
const visit = (v: unknown): void => {
41+
if (found) return;
42+
if (Array.isArray(v)) {
43+
for (const item of v) visit(item);
44+
return;
45+
}
46+
if (!isRec(v)) return;
47+
if (v.id === id) {
48+
found = v;
49+
return;
50+
}
51+
for (const child of Object.values(v)) visit(child);
52+
};
53+
visit(root);
54+
return found;
55+
}
56+
57+
describe('showcase money declarations', () => {
58+
it('the expense report money roll-ups declare two decimals', () => {
59+
const f = fields(ExpenseReport);
60+
for (const name of ['total_amount', 'approved_amount', 'reimbursable_amount']) {
61+
expect(f[name]?.type, name).toBe('summary');
62+
expect(f[name]?.scale, `${name}.scale`).toBe(2);
63+
}
64+
});
65+
66+
it('project budget and spent carry the fixed currency Accounts uses', () => {
67+
const revenue = fields(Account).annual_revenue?.currencyConfig;
68+
expect(revenue).toEqual(USD_FIXED);
69+
const f = fields(Project);
70+
expect(f.budget?.currencyConfig).toEqual(revenue);
71+
expect(f.spent?.currencyConfig).toEqual(revenue);
72+
});
73+
74+
it('budget_remaining declares a currency result, rounded to cents', () => {
75+
const remaining = fields(Project).budget_remaining;
76+
expect(remaining?.type).toBe('formula');
77+
expect(remaining?.returnType).toBe('currency');
78+
expect(remaining?.currencyConfig).toEqual(USD_FIXED);
79+
expect(remaining?.scale).toBe(2);
80+
});
81+
82+
it('the currency result is the one authoring would stamp for its expression', () => {
83+
const f = fields(Project);
84+
const remaining = f.budget_remaining!;
85+
expect(inferFormulaReturn(remaining.expression as never, f as never)).toEqual({
86+
returnType: remaining.returnType,
87+
currencyConfig: remaining.currencyConfig,
88+
});
89+
});
90+
91+
it('both dashboards write the total budget with one format and one currency', () => {
92+
const tile = nodeById(CommandCenterPage.regions, 'cc_k6');
93+
const props = (tile?.properties ?? {}) as AnyRec;
94+
expect(tile?.type).toBe('object-metric');
95+
expect(props.aggregate).toEqual({ field: 'budget', function: 'sum' });
96+
97+
const widget = (OpsDashboard.widgets as AnyRec[]).find((w) => w.id === 'kpi_total_budget');
98+
expect(widget?.values).toEqual(['budget_sum']);
99+
const measure = ShowcaseProjectDataset.measures.find((m) => m.name === 'budget_sum');
100+
expect(measure).toMatchObject({ aggregate: 'sum', field: 'budget' });
101+
102+
// The dataset-bound tile takes its currency from the measure, else from
103+
// the aggregated field's fixed currency; the object-metric tile from its
104+
// own `currency` only.
105+
const datasetCurrency = measure?.currency ?? (fields(Project).budget?.currencyConfig as AnyRec | undefined)?.defaultCurrency;
106+
107+
expect(props.format).toBeDefined();
108+
expect(props.format).toBe(measure?.format);
109+
expect(props.currency).toBeDefined();
110+
expect(props.currency).toBe(datasetCurrency);
111+
});
112+
});

0 commit comments

Comments
 (0)