Repository navigation
feat(i18n): render instants in the served display zone (objectui#11693) - #11729
Conversation
The console's localization fetch dropped the `timezone` that `GET /api/v1/auth/me/localization` answers. It now carries it in `LocalizationValue`, and `LocalizationProvider` hands it to the one date-display path in `@object-ui/core` (`setDisplayTimeZone`), which every date and datetime face reads. An instant renders in that zone; a date-only value keeps the day it names, including when a caller hands on the `Date` that `toDisplayDate` built for it. With no zone set, every face renders in the viewer's zone, as before. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…tch (objectui#11693) A datetime renders in the set zone and in the viewer's zone when none is set; a date-only value names its day under a zone west of the viewer, including the Date toDisplayDate hands on; the provider re-renders its consumers when the zone arrives after the first render; the console carries the served zone. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Core's README documents setDisplayTimeZone and its two readers, i18n's documents LocalizationProvider with the timezone it now carries (and says its own date helpers do not read the display zone), and the datetime field guide says which zone a datetime renders in. The changeset is minor for core, i18n and the console. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…(objectui#11693) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
|
Gate pointer: this PR waits on #11758 · director seat, summon #35, The ruling on #11693 (batch #283 item 3, letter B with gate B2, on the maintainer's delegation 「总监决裁」) lands this PR as built, after #11758 closes: the one-time administrator prompt that sets a defaulted workspace timezone from the browser's zone on confirm, so that merging this PR does not switch every unconfigured workspace's display to UTC. #11693 is Generated by Claude Code |
Contract reviewServed-tier: Inputs: card #11693 (body and its five comments: claim ① Derived judgments
Nothing in the diff is judged WRONG. ② Semver level
Clause-②: yes — matches the claim ( ③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #11693
Clause-②: yes
The console's localization fetch dropped the
timezonethatGET /api/v1/auth/me/localizationanswers. It now carries it, and every date and datetime face built on@object-ui/core's date-display functions renders an instant in that zone. A date-only value keeps the day it names, and with no zone every face renders in the viewer's zone, as before.Gate: this PR merges only after #11758 closes. That is the director's ruling
6030273339on #11693: letter B with gate B2, on the maintainer's delegation 「总监决裁」. The server answersUTCfor a workspace that configured no zone, so this PR alone would move those workspaces' datetimes from the viewer's zone to UTC. #11758 first asks an administrator, once, to set the workspace timezone (pre-filled with the browser's zone, confirm to write). The PR stays draft until #11758 closes. Then thedomain:uiseat re-checks it (Unlock-action: re-check PR #11729on #11693), runs the at-tier contract review, and queues it. Nothing in this PR changes for the ruling. (Gate line added bydomain:uiseat 3,session_01CGZy1BGCjdN5cXqL9cnvB8.)Measured first, on
origin/main7300fca, before the changegit grep -n -i -E 'time_?zone' -- 'packages/*/src/**' 'apps/console/src/**'without tests and locale packs, 60 lines in 19 files. No renderer reads a session zone or a per-field zone. The hits that carry a zone are plugin-gantt's per-viewGanttConfig.timeZone(its chart frame,makeTzShift), the report schedule'stimezone, job and flow previews in metadata-admin, and the settings page'siana_time_zonedomain. The rest are comments. Control: the same search hits the console's ownMeLocalizationResponse.timezone, the declaration the card says is dropped, so it reaches the file in question.formatDate,formatDateTime,formatDateTimeCompactPartsandformatRelativeDatein@object-ui/core(utils/date-display.ts). The field cells and widgets, the data-table cell, gantt tooltips, grid and dashboard dates and the dataset measures all call them, threading{ locale }fromuseDisplayLocale()and no zone. The i18n formatters the card names (utils/formatting.ts, andformatDateSpec, which already takes atimeZone) have no caller outside@object-ui/i18nin source:git grep -n -E '\b(formatDateTime|formatDateTimeCompactParts|formatDateSpec|applyLocaleConfig)\('over non-test source. So the zone is implemented where the faces are, in core. Handing it toformatDateSpecwould reach no renderer.resolveCurrentUserLocalization(plugin-hono-server,current-user-endpoints.ts):timezoneis the workspace'slocalization.timezone(platform default, then global, then tenant), typed "Always a string — the cascade's floor isUTC".Default timezone,default: 'UTC', described as the "IANA zone used to resolve today()/daysFromNow, analytics date buckets, and rendered datetimes".sys_userdeclares none, and the manifest puts per-user overrides out of scope for v1.The change
@object-ui/coredate-display.ts. A display zone:setDisplayTimeZone,getDisplayTimeZone,subscribeDisplayTimeZone.Intloptions as before.toDisplayDateandtoInclusiveEndDayregister the calendar-dayDates they build (aWeakSet), so the exemption holds when a caller hands thatDateon, asGridField's date cell does.Intldoes not know clears the zone with a warning, rather than making every face throw.@object-ui/i18nLocalizationContext.tsx.LocalizationValue.timezone.LocalizationProviderhands it to core in a layout effect and reads the applied zone back withuseSyncExternalStore. Its context therefore changes only after the faces have the zone, and every consumer re-renders in it before paint.LocalizationFetchProvider.tsx. Keepstimezonefrom the answer.minorfor core, i18n and the console.Why one ambient zone rather than a
timeZoneoption at each call site. About twenty call sites in seven packages thread{ locale }today. Threading a zone the same way would leave any forgotten call site rendering the same instant in a second zone beside the first, which is objectui#4272's shape for the locale. It would also editpackages/fields/src/widgets/*, which objectui#11689 is editing in parallel.Tests, at
229db2bdate-display.displayZone-11693.test.ts(core, 16),LocalizationContext.timezone-11693.test.tsx(i18n, 5), and two cases in the console'sLocalizationFetchProvider.test.tsx. The suite runs in UTC, so UTC stands in for the browser's zone.America/Los_Angelesset,2026-09-02T03:00ZrendersSep 1, 2026, 08:00 PM. With none set, it rendersSep 2, 2026, 03:00 AM.2026-09-01rendersSep 1under the west zone on every face, including theDatefromtoDisplayDateand the inclusive end day. Control: the same instant as a plainDaterendersAug 31.pnpm exec vitest run packages/core/ packages/i18n/ apps/console/src/LocalizationFetchProvider.test.tsx apps/console/src/__tests__/localizationBootDedup.test.tsxgivesTest Files 281 passed (281),Tests 5257 passed | 40 skipped. This includesdate-display.dateOnlyZone-10110, which spawns realTZ=America/Los_AngelesandTZ=Asia/Shanghaichildren over this module, so the relative face's rewritten day arithmetic is measured unchanged outside UTC.82 passed | 2 skippedand83 passed | 1 skipped(1116 and 1180 tests, 0 failed). They are every test that mountsLocalizationProviderorLocalizationFetchProvider, plus the date and datetime suites of the packages that call the faces (fields, components, plugin-grid, plugin-gantt, plugin-dashboard and others).pnpm --filter @object-ui/core type-checkand the same for@object-ui/i18nexit 0. The console's is narrowed (below).check:control-bytes,check:test-path-roots,check:changeset-claims(corpus: 1 changeset),check:pending-changeset-literals,check:new-line-citations(0 new),check:doc-fences,check:doc-example-ids,check:doc-example-readers,check:doc-types,check:phantom-deps,check:self-import,check-changeset-presence, andcheck-governed-queue-guard --test(NOT GOVERNED).eslinton the six touched source and test files gives 0 errors. Its 2 warnings are on lines that predate this branch.type-check,check:readme-exports,check:doc-snippetsandcheck:doc-examplesread every package's builtdist/, and 32 packages are unbuilt here (the three gates exit "could not run"). In their place:timezonekey reds as TS2353, so the compile reads the rebuiltLocalizationValue.check:readme-exports --json, filtered to the core and i18n READMEs: 0 findings, with 96 self-imports judged real and 0 fabricated. Control: the same filter finds 22 entries for app-shell's unbuilt README.Ablations
One-time proofs, run through
ablation-replace.mjs(the mutation must land on disk). Each was restored toHEAD, proved by blob hash and an emptygit diff HEAD.timezonetoDisplayDatestops registering its calendar dayAug 31orYesterday; the inclusive-end pin and the control stay greentoInclusiveEndDaystops registeringAmerica/Los_Angeleswhile the face still renders03:00 AMsetDisplayTimeZonea numbertype-checkreds from core's rebuilt declarationsThe first attempt at the fourth row was refused by the tool and ran nothing, because its replacement text was a substring of its anchor. It was rerun with a distinct marker.
Open question that gates the merge
The card asks: "with none set, it renders in the browser's". The wire never says "none": a workspace that configured no zone is answered
UTC, the cascade floor above. Merged alone, this PR moves every such workspace's datetimes from the viewer's zone to UTC. The client code here is the same under A, B and D; D adds one line in the fetch provider.timezone: nullwhen only the default answered (the settings service reportssource: 'default'). Server-sideTODAY()and buckets keep their UTC floor, and unconfigured workspaces keep the viewer's zone. This reverses the endpoint's pinned "never null" fortimezone.TODAY()and analytics buckets share one zone. No further change.UTCas unset. Not offered: a workspace that really chose UTC would render in the viewer's zone, and it is the consumer-side tolerance AGENTS.md #0.1 forbids.timezoneSource). Additive, at the cost of one more field on the wire.Recommendation: B. It honours the zone the settings contract already declares for rendered datetimes. It leaves one zone for display,
TODAY()and buckets, with no client-only rule that "null means the browser". It needs no further change. The cost is that unconfigured workspaces show UTC until an admin sets the zone. If that is unacceptable, the lever is the default (provisioning, or the manifest'sdefault), not a client fallback. A is the choice if the unconfigured workspace's experience outweighs one shared zone.Acceptance notes
git grep -n -E '\.toLocaleString\(|\.toLocaleTimeString\(|new Intl\.DateTimeFormat\(|\.toLocaleDateString\('over non-testpackages/*/srcandapps/console/src, excludingdate-display.tsand i18n's utils. It over-counts, becausetoLocaleStringalso matches numbers.formatDate,formatDateTime,formatRelativeTime,formatDateSpec) do not read the display zone. They have no renderer caller in this repository, and the i18n README now says so.TODAY()still answers the UTC day. Its docblock names "a later card" for when the zone reaches the client; it now does (getDisplayTimeZone()).vitest.config.mts' header says the one zone-aware surface takes its zone from metadata. It is no longer the only one; that is comment text, not edited here.scripts/__tests__/date-only-zone-pins-10183.test.tsreds on itsFormulaFunctions.dateOnlyZone-10866file (expected '2026-09-01 11:00' to be '2026-09-01 03:00').unitproject runs withisolate: false. When vitest's results cache puts a file that leavesTZeast of UTC before it in a shared fork, the leakedTZreaches it.7300fca: with a fresh cache it passes 27/27; with the cache this branch had, it fails identically. This branch's runs fail the same way, so this diff is not the cause.Generated by Claude Code