Uh oh!
There was an error while loading. Please reload this page.
fix(core,fields): render a dataset measure over a date field as a date - #7442
Conversation
`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`, placed before `format` was ever read. A `min` / `max` measure over a date or datetime field therefore printed its stored value verbatim -- a 24-character ISO string in the KPI tile's `text-2xl font-semibold`, wrapping to two lines -- and the `format` that `DatasetMeasureSchema` accepts could never be read on that path. A date-shaped value now routes to the date display path ahead of that short-circuit, so all four dataset-bound surfaces are served at once. No second date formatter was written. `formatDate`, `formatDateTime`, `formatRelativeDate` and `DateDisplayOptions` MOVED from `@object-ui/fields`' barrel down into `@object-ui/core` (`utils/date-display.ts`) -- the same remedy objectui#4576 applied to `formatDisplayNumber`, for the same reason: `core` is the React-free engine and could not import from a React package, so the alternative was a parallel date convention in `dataset-format.ts`, which is the drift #4576 already paid for once in percent. `@object-ui/fields` re-exports all four names unchanged and a reference-identity test pins that the cell renderer and the measure formatter call the same function object. Numeric measures are byte-identical: 33,696 argument forms compared against a verbatim copy of the pre-fix function, and the only values that moved were the four ISO-shaped, parseable ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
❌ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. Which half objected:
📦 Bundle Size Report
Size Limits
|
os-project-manager
commented
Sep 3, 2026
Blocked, deliberately: |
| chunk | before | after | ceiling |
|---|---|---|---|
framework | 523,823 | 524,402 (+579) | 524,000 — over by 402 |
ui-components | 396,598 | 396,088 (−510) | 399,000 |
| aggregate eager closure | 3,254,604 | 3,254,620 (+16) | passes |
It is this PR's byte cost, and it is still not this PR's to fix
The failure is real and attributable — I am not claiming otherwise. But the bytes are a relocation, not growth: the framework group is defined by path (apps/console/vite.config.ts:742, /packages/(core|react|types)/), so code moving intopackages/core scores against it even though an equal amount left ui-components. This PR's true payload cost is +16 bytes across a 3.25 MB closure, and the aggregate ceiling passes. CI's own package table shows both halves: fields 61.71 → 61.26 KB gz, core 2.32 → 2.42 KB gz.
The relocation is not optional. The ruling on #7178 requires routing through the date path list cells already use and forbids a second date convention; packages/core is the React-free engine and cannot import from packages/fields, so the shared formatDate / formatDateTime had to move down — the same move objectui#4576 made for formatDisplayNumber.
What I am NOT doing, and why
- ⛔ Not raising
PER_CHUNK_GZIP_CEILINGS.framework. A gate ceiling is the maintainer's call and is the open subject of [Decision] Theframeworkper-chunk eager-closure ceiling leaves 177 bytes for the whole repo — two ruled user-facing fixes cannot land, and no wording of either fits #7399. Raising it to fit my own PR is the one thing that card exists to prevent. - ⛔ Not shrinking the change to fit. That means keeping a date convention inside
dataset-format.ts— the second-convention failure the ruling and [core][fields] A percent renders as1.234,5 %in a list cell and1.234,5%as a dashboard measure — two percent conventions for one number #4576 both forbid. It would trade a governance blocker for exactly the defect this card exists to fix. - ⛔ Not re-running the check. This is a deterministic budget computation, not a flake: it reproduces on two full local builds with CI's own recipe and on CI. A re-run would return the identical number and burn a runner for nothing. The re-run allowance exists for a failure that might not reproduce; this one will.
- ⛔ No test skipped, disabled or quarantined. No baseline re-pinned, no budget script touched.
Where it goes
Parked behind #7399 (the open framework ceiling decision), alongside #7391 and #7400 — this is now the third PR blocked there. New evidence posted to #7399 at 5520783051: this PR is the first case where the gate reds on a change whose aggregate cost is +16 bytes, which is a property of how the per-chunk ceiling attributes bytes rather than of the change.
Full review of the implementation — including the one place the ruling's pin is not literally satisfiable for datetime, and why option A was taken anyway — is on the card at 5520776537. The PR stays draft; auto-merge is not enabled and it has not been enqueued.
Generated by Claude Code
os-project-manager
commented
Sep 3, 2026
✅ Unblocked — the ceiling this was parked behind is fixed. |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-project-manager
commented
Sep 3, 2026
✅ Landing — 32/32 green, ready + auto-merge
Every check read by name, not in aggregate32 total: 27 success, 5 skipped, on head The ones that were the actual question:
The landing authority, statedClause-② is no on this card, ruled explicitly: 「Execution:
⇒ Seat lands it. The implementation review is the ACCEPT on the card (#7178 comment 5520776537). The one flag carried into landing, deliberatelyThat ACCEPT recorded, and did not paper over, that the ruling's pin is not literally satisfiable for That objection window has been open since 05:06 today and the director seat has been active in this repo since (it ruled #7402 at 08:44). No objection was entered, so the flag lands as recorded rather than silently. The reconciliation itself is filed as #7443 and is not closed by this PR. Follow-up dischargedThe ruling also owed a one-line spec-lane follow-up, "filed by the ⭐ It does not copy the ruling's suggested sentence. The ruling assumed a date measure cannot read Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7178
Implements the director-seat ruling in comment 5509025252 (summon #8, 2026-09-02, maintainer verbatim
7189 A 其他同意) — option A.min/maxover a date or datetime field stays a legal measure; nothing in@objectstack/specis touched, andDatasetMeasureSchemais not narrowed.The defect
formatMeasureinpackages/core/src/utils/dataset-format.tsopened with:The second line runs before
formatis read, so for any non-numeric value the function wasString(v)and nothing else. Aminover a datetime rendered as a raw 24-character ISO string in the KPI tile'stext-2xl font-semibold, wrapping to two lines, and theformatthe four call sites carefully thread through was dead on this path.A date-shaped value now routes to the date display path ahead of that short-circuit. Because the fix lands in
formatMeasure, all four call sites are served at once — the metric tile (DatasetWidget.tsx:768), chart values (:876), dataset table cells (:1083) and the metadata-admin dataset preview (DatasetPreview.tsx:235). None of them changed.PivotTable.tsxtakes anumberoutright and is untouched.⭐ The shared date path — what I found, and what reusing it cost
File + symbol:
formatDate/formatDateTime/formatRelativeDate, previously inpackages/fields/src/index.tsx(@object-ui/fields), now inpackages/core/src/utils/date-display.ts.That is the path a list cell uses:
DateCellRenderer(registered for field typedateatpackages/fields/src/index.tsx) callsformatDate(value, field.format || 'relative', { dueLike, locale, t }), andObjectGrid's date cells,ObjectGantt's tooltips andplugin-dashboard'srecordFieldsall call the same two functions.Reusing it required lifting it, and that is the substance of this PR.
@object-ui/fieldsis a React package and depends on@object-ui/core;coreis the React-free engine, so it could not import from it. That is exactly the objectui#4576 situation one type over — there,formatDisplayNumberlived in@object-ui/i18n,dataset-format.tscould not reach it, the two implementations drifted, and a German session read1.234,5 %from a list cell beside1.234,5%from a dashboard measure. #4576 removed the boundary by moving the pure function down intocoreand re-exporting from above.This PR applies that same remedy:
DateDisplayOptions,formatRelativeDays,formatRelativeDate,formatDateandformatDateTimemove verbatim intopackages/core/src/utils/date-display.ts. No logic changed.@object-ui/fieldsre-exports all four public names, so no consumer's import path or behaviour changes (ObjectGrid,ObjectGantt,plugin-dashboard,@object-ui/react).packages/fields/src/__tests__/date-display.reexport-identity.test.tspins reference identity — the cell renderer'sformatDateand the measure formatter's are the same function object. A second copy would pass every behavioural assertion in the repo and fail only there.⛔ No date formatter was written in
dataset-format.ts. The new code there is a 12-line route: two anchored ISO shape tests, aDate.parseguard, and a call.The date/datetime split mirrors
ObjectGantt.tsx's sniffed-ISO dispatch verbatim, because that site is already the repo's answer to this exact situation — an ISO string with no field type attached, which is what a measure is by construction. That is why the absolute locale form is used rather thanDateCellRenderer's|| 'relative'default, which is keyed on knowing the value is adatefield in a row.⭐ The
formatreachability answer — measuredThe shared date path accepts
formatas a named STYLE, not as a date pattern.formatDate(value, style, options)'s vocabulary is'short'and'relative'; every other string falls to its default locale-medium branch. So, for a date measure:format'short'Jul 4, '24— the same wordDateCellRendererhonours fromfield.format'relative'3 days ago'YYYY-MM-DD'Jul 4, 2024)Threading the measure's
formatinto that parameter is the same mapping the list cell already makes, not a new one — and it is not novel behaviour for a pattern either:plugin-dashboard'srecordFields.tsxalready routes a date-shapedformat(/[YMDHms]/) into the same style slot and gets the same locale default.⇒ Per the ruling's contingency, the remaining gap is the pattern case, not the whole of
format. If the spec seat files the one-linedescribe()follow-up, "applies to numeric measures; date measures render by locale" would be slightly too strong —'short'and'relative'are read. Suggested wording: "numeral patterns apply to numeric measures; a date measure readsformatas a display style (short,relative)." That follow-up is thedomain:uiseat's to file — it is not in this PR.The limit is pinned by a test rather than left silent, so teaching the shared path a pattern grammar later has a case that must be updated deliberately.
Tests
pnpm exec vitest runfrom the repo root (packages/core/src/utils/__tests__/dataset-format.date.test.ts— 13 passedpackages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx— 5 passed (the ruling's pin: metric tile + dataset table)packages/fields/src/__tests__/date-display.reexport-identity.test.ts— 2 passedEvery expected date string in the rendered-surface tests is derived by calling
formatDateTimefrom@object-ui/fields— the module the cell renderers call, by the path they import it by — so the cases assert "the tile shows what a list cell would show", not "the tile shows the string I typed". A literal would pass just as well against a second date convention.⭐ A numeric measure is byte-identical
A verbatim
git showcopy of the pre-fix function was compared against the new one across the full argument matrix (value ×format×currency×percentScale×locale):33,696 forms compared; the only values that moved were the four ISO-shaped, parseable ones. Numbers, numeric strings (
'1751612400000','2026','1e21'), the nullish em dash, arbitrary prose ('Acme Corp','March 5, 2026','2026/07/04') and non-strings are all byte-identical.Two findings from that sweep, both now pinned:
Date.parse('2026-02-30')is notNaN— V8 rolls it to March 2. It routes to the date path and rendersMar 2, which is what a list cell shows for the same stored string. That is agreement, not a defect; rejecting it here is what would create a second convention.'2026-13-45','2024-07-04T99:99') keeps falling through toString(v). Routing it toformatDatewould replace a raw string an author can still debug with an em dash that says nothing.Red-then-green ablation
Two mutations, each proven on disk by blob hash + anchored grep counts before the run, each restored with
git checkout HEAD -- ABSOLUTE_PATHundertrap ... EXIT INT TERM, and each restore proven by state (git diff HEADempty and blob hash equal to theHEADblob), never by exit code. Directions were written down before the run and all four matched.Vitest aliases every workspace package to
src(vitest.config.mts), so these legs read source directly — no rebuild leg applies, and none is claimed.return String(v)short-circuit8 failed / 5 passed, dashboard3 failed / 2 passed— every date andformatcase red; ⭐ every numeric must-not-change case stayed green1 failed / 1 passed— identity red, behaviour greenLeg 2 is the one that matters for the ruling's constraint: it is a "second date formatter" that behaves identically, and it is caught only by the identity pin. That is the drift objectui#4576 paid for once.
Also run
type-checkandlintclean (exit 0) on@object-ui/core,@object-ui/fields,@object-ui/plugin-dashboard, after building each dependency closure.Bundle budget —
frameworkchunkframework402 bytes over its ceiling. TheBundle Analysischeck will be red. Reported, not worked around — and ⛔ no ceiling, budget script orPER_CHUNK_*entry is touched in this PR.Measured with CI's own recipe (
pnpm turbo run build --filter='./packages/*'→pnpm --filter @object-ui/console build→node scripts/check-eager-closure-budget.mjs) on both legs: this branch at5ad78fde, and the branch point6411def25as the baseline. Gzipped bytes, read fromapps/console/dist/eager-closure.json:frameworkui-componentsvendor-objectstackVerdicts: baseline
exit 0(green), this branchexit 1—What the bytes are. They are not new code. This change is a relocation: the date formatter family leaves
packages/fields(ui-components, −510 B) and lands inpackages/core(framework, +579 B), becauseframeworkis/packages/(core|react|types)/by path. The net cost to the payload a browser actually fetches is +16 bytes across a 3.25 MB eager closure — the two chunk rows nearly cancel, and the aggregate ceiling keeps 13.1 KB of headroom.So the red is chunk attribution, not payload growth:
frameworkhad 177 bytes of headroom before this branch, and moving a shared function into the package that group is defined by consumes it. The relocation is the ruled design —coremust own the date path because it is the React-free engine thatformatMeasurelives in — so shrinking it back means either a second date formatter indataset-format.ts(⛔ forbidden by the ruling and by objectui#4576) or leaving the defect unfixed.⇒ This lands on the same blocked ceiling as #7391 and #7400, pending the maintainer's decision in #7399. Routing that is the PM's call, not this PR's.
Changeset
@object-ui/core: patchand@object-ui/fields: patch. The ruling named a@object-ui/corepatch;@object-ui/fieldsis added because the lift genuinely changes that package's source and its dependency oncorefor those symbols. Flagging it as a deliberate, minimal deviation rather than a silent one.🤖 Generated with Claude Code
https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
Generated by Claude Code