Skip to content

Commit 2a70334

Browse files
committed
fix(app-showcase): write showcase money one way
The expense report money roll-ups declare scale 2, project budget and spent declare the fixed USD currency Accounts uses, budget_remaining declares a currency result, and both dashboards write the total budget with one pattern and one currency. A pin holds the declarations. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy
1 parent 8ef2a96 commit 2a70334

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)