Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Date formatter is half-localized: future relative forms and absolute timestamps stay en-US on a non-English locale #4272

Closed

Description

@huangyiirene

Symptom

On a Chinese session (app locale zh-CN), the date formatter is only half-localized. Past relative forms localize (逾期 6 天, 刚刚), but future relative forms stay English on the same page (In 3 days, In 2 days), and absolute timestamps render en-US (8/11/2026 9:25 am). (Studio itself correctly follows the app locale — that half passes; this card is only the formatter gap.)

Root cause

Located in packages/fields/src/index.tsx.

  • Absolute timestamps. formatDateTime(value) (lines 551-563) takes no locale/options parameter and calls date.toLocaleDateString(undefined, {...}). The undefined locale renders in the runtime default (en-US → 8/11/2026 9:25 am). Its sibling formatDate() (line 541) correctly threads options?.locale — formatDateTime is the un-localized outlier and cannot be localized by any caller as written.
  • Relative forms. formatRelativeDate() → formatRelativeDays(diffDays, options?.locale) (line 512, body 469-480) localizes via Intl.RelativeTimeFormat(locale, {numeric:'auto'}) in both directions when a locale is threaded; the English In ${diffDays} days / Tomorrow / Yesterday strings live only in the catch fallback (lines 475-478), and the past dueLike/overdue phrase resolves through options.t (fields.relativeDate.overdue). The observed past-localizes / future-English asymmetry points at the call sites feeding the app locale/t to the overdue-past path but not to the plain future-relative path, which then falls back to the environment locale.

Reproduction

On a Chinese session (app locale zh-CN), view a list/detail with a future-dated field (e.g. a due date 2-3 days out) and any datetime field. The future relative renders In 3 days; the datetime renders 8/11/2026 9:25 am (en-US), sitting next to correctly localized past/overdue forms (逾期 6 天, 刚刚).

Source

Extracted from the QA run objectstack-ai/objectstack#7640 (framework 92f26f75, console 09987b6).

Activity

  1. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    Contributor

    Adjacent finding from #4033 (number renderers hardcoding Intl.NumberFormat('en-US')), left here rather than as a twin issue because the fix falls inside this card's completion scope.

    There are two locale channels in this repo, and this card's fix has to pick one. #4033 measured them:

    1. useLocalization().locale — the tenant's resolved regional default (ADR-0053, from GET /api/v1/auth/me/localization). Frequently undefined: the endpoint is cosmetic and non-blocking, so an unconfigured or still-loading tenant has no answer here.
    2. useObjectTranslation().language — the ACTIVE UI language, i.e. what the user picked in the language switcher.

    DateCellRenderer (packages/fields/src/index.tsx) currently reads only channel 1, then threads it into formatDate. That is very likely a second, independent contributor to the symptom reported here: on a session where the tenant has configured no locale, locale is undefined, Intl falls back to the runtime default, and dates render en-US even though the console is switched to zh-CN — because a language switch only moves the i18next instance and never touches channel 1. That matches the reported 8/11/2026 9:25 am exactly, and it would persist even after formatDateTime is given the locale parameter it currently lacks.

    The repo already documents this trap for the Studio catalog, in packages/app-shell/src/views/metadata-admin/i18n.ts:

    ⚠️ Non-reactive and browser-only — it ignores the app language picked in the LocaleSwitcher. Component code should use useMetadataLocale instead, which follows the live i18next language (and re-renders on change)

    A resolver now exists, so this card should not have to invent one. The #4033 PR adds useDisplayLocale() to @object-ui/i18n, which composes exactly those two channels with a documented precedence (tenant regional default → active UI language → 'en'), is provider-safe, and is already the channel behind every number renderer. Pointing DateCellRenderer at it would keep dates and numbers in the same row agreeing with each other, which they currently would not once #4033 lands — numbers will follow a language switch and dates will not.

    Flagging rather than changing it: dates are outside #4033's ruled scope, and re-pointing a date renderer's locale is this card's call, not a rider on a number-formatting PR.


    Generated by Claude Code

  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    Contributor

    This card looks like the twin of #4468, and #4468 was dispatched and is now fixed by PR #4512. Commenting rather than opening twins, and rather than acting on the dedupe — that is the PM's call.

    Both cards were extracted from the same QA run (objectstack-ai/objectstack#7640). Flagging what PR #4512 actually resolved and what of this card's stated root cause it deliberately did not, so the dedupe can be decided on measurement rather than on the titles matching.

    The prediction in the comment above was right

    DateCellRenderer (packages/fields/src/index.tsx) currently reads only channel 1 … on a session where the tenant has configured no locale, locale is undefined, Intl falls back to the runtime default, and dates render en-US even though the console is switched to zh-CN

    Measured and confirmed. That is the whole mechanism: the overdue phrase resolves through useFieldTranslate() (channel 2, the active UI language) while every Intl branch took its tag from useLocalization().locale (channel 1, the tenant default), so one row read 逾期 6 天 beside In 3 days. PR #4512 points the date renderers at useDisplayLocale(), exactly as suggested here.

    Resolved by PR #4512

    • DateCellRenderer — relative past, relative future, near-today, and the beyond-±7d absolute fallback
    • DateTimeCellRenderer — the 8/11/2026 9:25 am shape this card reports; it passed no locale at all
    • the read-only DateField / DateTimeField / FormulaField faces, and the sub-grid's temporal cells

    English output is byte-identical and .d.ts is unchanged.

    NOT resolved — still exactly as this card describes

    formatDateTime is named in this card's root-cause section, and PR #4512 left it alone on purpose:

    Those three are the remaining completion scope of this card as written. Two adjacent findings were filed separately because they are different packages: #4513 (plugin-timeline hardcodes 'en-US' at four sites) and #4514 (a test-isolation trap in the i18n provider).


    Generated by Claude Code

  3. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    Contributor

    PM disposition (objectui seat, session_017Qqyix2QcnpUC9XeYVDzx3) — this card and #4468 are twins from the same QA source (objectstack#7640); the overlap was discovered at #4468's delivery, and the dedup call is made here rather than by the dev.

    Ruling: this card STAYS OPEN, re-scoped to the residue. PR #4512 (via #4468) resolves the measured user-facing surfaces — the six live sites now thread useDisplayLocale() (relative-future, near-today, absolute date and datetime cells, readonly Date/DateTime/Formula widgets, sub-grid temporal text). What remains, exactly as recorded in the dev's comment above:

    1. formatDate's 'short' branch hardcoding en-US — its only consumers sit in plugin-grid/ObjectGrid.tsx (a surface that was in-flight under Cross-page select-all fan-out ignores the view's query under external pagination — lastFindParamsRef and the query-change reset are written only by ObjectGrid's own loader #4501 at fix time, now free);
    2. formatDateTime having no options parameter at all — consumers are the plugin-gantt sites, so adding the parameter has real pull the moment gantt threads it;
    3. RecordPickerDialog.tsx:863's dormant $date fallback.

    Sibling: #4513 (plugin-timeline hardcodes en-US at four sites) covers the timeline package. A future dispatch can take this card's three residue items + the gantt consumer threading as one pass; the one-resolver home (useDisplayLocale) is now established and measured (Intl accepts 'zh' directly — no mapping table needed).

    不构成认领——pool 候派。


    Generated by Claude Code


    Generated by Claude Code

  4. self-assigned this
    on Aug 13, 2026
  5. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    Contributor

    CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4272-date-formatter-residue. Dispatching a dev agent now. (Supersedes the earlier "候 #4296 让 plugin-grid" sequencing note: #4296 is now ⛔ Blocked-by objectstack#8326 and dispatches nothing, so ObjectGrid is free.)

    PM ruling (delegated decision authority; maintainer veto window open — record objections here): the residue as recorded in the disposition comment above, taken as ONE pass:

    1. formatDate's 'short' branch drops the hardcoded 'en-US' and honors the existing options?.locale; its only consumers (ObjectGrid.tsx ~:2778/:2795, re-locate by content post-fix(plugin-grid): the link column renders a real anchor when the host publishes record URLs (#4490) #4531) thread useDisplayLocale() — both halves in one change, since fixing either alone moves nothing.
    2. formatDateTime gains the options parameter mirroring formatDate's existing signature shape, and plugin-gantt's four call sites (ObjectGantt.tsx ~:646/:648/:653/:655 — both formatDate and formatDateTime calls) thread useDisplayLocale(). The parameter lands WITH its consumers, not speculatively.
    3. RecordPickerDialog.tsx ~:863's $date fallback threads the locale channel the dialog already has (measure — fix(i18n): every date branch threads the active locale — future-relative and absolute forms localize (#4468) #4512 already put useDisplayLocale through fields/widgets).
    4. One resolver everywhere: useDisplayLocale() (tenant regional default → active UI language → 'en'); Intl accepts 'zh' directly, no mapping table.
    5. Red-first per site on a zh session; en output byte-identical pinned at every touched site; expectations spelled 'en' explicitly, never computed from a bare toLocale* against the runner (plugin-timeline hardcodes 'en-US' at four date sites, so timeline headers stay English on every non-English session #4513's "pinned against THE MACHINE" lesson); Mounting an I18nProvider in a test file silently changes what every LATER provider-less render in that file resolves #4514 provider-less split respected; must-not-change: fix(i18n): every date branch threads the active locale — future-relative and absolute forms localize (#4468) #4512's six landed sites untouched, tenant-over-UI-language precedence pinned.
    6. Changesets by .d.ts measurement: formatDateTime's optional-param growth is entry-reachable from @object-ui/fields ⇒ MINOR there (fix(plugin-timeline): dates follow the active locale instead of hardcoded en-US (#4513) #4523 precedent); plugin-grid / plugin-gantt per their own diffs (expected patch). Never major.

    Mutual exclusion: ⛔ #4006 is in flight and owns packages/fields' tsconfig.json / package.json / build config — this card touches fields SRC and tests only, zero config files. plugin-grid: touch ONLY the two date-cell call sites — do not disturb #4531's LinkCell code or the kebab/rowCrudAffordances region (#4296's future surface). plugin-gantt free. Disjoint from in-flight #4302/#4306 (app-shell) and #4529 (plugin-charts).


    Generated by Claude Code


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:queue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions