From f5a325f8a0ee7905b7df6d87e273b9143fdcc588 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:49:44 +0000 Subject: [PATCH 1/2] fix(service-settings)!: retire date_format, time_format, number_format and first_day_of_week from the Localization manifest (#21958) Formats follow the locale (ruled on objectui#11675, B). The four rows, the now-empty Formats group and their entries in the en, es-ES, ja-JP and zh-CN bundles leave; the manifest version goes to 2 per the spec's SettingsManifest.version contract (increment when keys are removed). A stored sys_setting row for one of the four is kept: it is not resolved on read, a write naming it is refused 400 UNKNOWN_KEY, and no reset or save of the live keys rewrites or deletes it. Pinned at the REST surface. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../manifests/localization.manifest.test.ts | 30 ++++-- .../src/manifests/localization.manifest.ts | 64 ++++--------- .../src/settings-routes.test.ts | 95 +++++++++++++++++++ .../src/settings-service.test.ts | 13 +-- .../service-settings/src/translations/en.ts | 7 +- .../src/translations/es-ES.ts | 7 +- .../src/translations/ja-JP.ts | 7 +- .../settings-translation-coverage.test.ts | 44 ++++++++- .../src/translations/zh-CN.ts | 25 +---- 9 files changed, 186 insertions(+), 106 deletions(-) diff --git a/packages/services/service-settings/src/manifests/localization.manifest.test.ts b/packages/services/service-settings/src/manifests/localization.manifest.test.ts index 1008a9f12c3..19e961b0645 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.test.ts @@ -9,11 +9,13 @@ describe('localizationSettingsManifest', () => { expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow(); }); - it('declares namespace=localization, scope=tenant, version=1', () => { + it('declares namespace=localization, scope=tenant, version=2', () => { const parsed = SettingsManifestSchema.parse(localizationSettingsManifest); expect(parsed.namespace).toBe('localization'); expect(parsed.scope).toBe('tenant'); // 组织级 (per-user overrides out of scope for v1) - expect(parsed.version).toBe(1); + // 2 since four keys left (#21958): the spec's `version` contract is + // "increment when keys are renamed/removed". + expect(parsed.version).toBe(2); }); it('defaults to UTC / en-US — preserving pre-Phase-2 behavior when nothing is set', () => { @@ -25,8 +27,6 @@ describe('localizationSettingsManifest', () => { // plain number unless the workspace explicitly sets one (avoids surfacing // an unwanted "$"/"US$" on every amount that omits its own code). expect(byKey('currency').default).toBeUndefined(); - expect(byKey('date_format').default).toBe('YYYY-MM-DD'); - expect(byKey('first_day_of_week').default).toBe('monday'); expect(byKey('fiscal_year_start').default).toBe('january'); }); @@ -43,8 +43,7 @@ describe('localizationSettingsManifest', () => { expect(byKey('default_country').valueDomain).toBe('iso_3166_alpha2'); // The registry-backed selects stay UNDECLARED on purpose: their tables ARE // the supported sets (#5131 exhaustive semantics), not standards. - for (const key of ['locale', 'date_format', 'time_format', 'number_format', - 'first_day_of_week', 'fiscal_year_start']) { + for (const key of ['locale', 'fiscal_year_start']) { expect(byKey(key).valueDomain, `${key} must not declare a domain`).toBeUndefined(); } // And the declaration round-trips the spec parse (the enum is closed — @@ -63,15 +62,28 @@ describe('localizationSettingsManifest', () => { } }); - it('exposes the nine regional keys grouped into region/formats/finance', () => { + it('exposes the five regional keys grouped into region/finance', () => { const specs = localizationSettingsManifest.specifiers as any[]; const keys = specs.filter((s) => s.key).map((s) => s.key); expect(keys).toEqual([ 'timezone', 'locale', 'default_country', - 'date_format', 'time_format', 'number_format', 'first_day_of_week', 'currency', 'fiscal_year_start', ]); const groups = specs.filter((s) => s.type === 'group').map((s) => s.id); - expect(groups).toEqual(['region', 'formats', 'finance']); + expect(groups).toEqual(['region', 'finance']); + }); + + it('offers none of the four retired format keys, nor their empty group (#21958)', () => { + // Ruled on objectui#11675 (B): dates, times, numbers and the first day of + // the week follow `locale`; the four separate settings are retired, not + // implemented. Nothing ever read them, so offering them was a form that + // saved values with no effect. The Setup page renders exactly these + // specifiers (`GET /api/settings/localization` serves this manifest). + const specs = localizationSettingsManifest.specifiers as any[]; + const offered = specs.filter((s) => s.key).map((s) => s.key); + for (const key of ['date_format', 'time_format', 'number_format', 'first_day_of_week']) { + expect(offered, `${key} must not be offered`).not.toContain(key); + } + expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).not.toContain('formats'); }); }); diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index 5aa14a8cf8c..e8f3039af91 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -6,10 +6,23 @@ import type { SettingsManifest } from '@objectstack/spec/system'; * Localization — workspace-wide regional defaults (ADR-0053 Phase 2 follow-up). * * The single source of truth for the platform's reference timezone, language, - * currency, and display formats. `resolveExecutionContext` reads `timezone` - * and `locale` from here (cascade: platform default → global → tenant) onto - * every `ExecutionContext`, so formulas (`today()`), analytics date bucketing, - * and rendered `datetime` instants all resolve against the org's region. + * country and currency. `resolveLocalizationContext` (`@objectstack/core`) + * reads `timezone`, `locale` and `currency` from here (cascade: platform + * default → global → tenant) onto every `ExecutionContext`, so formulas + * (`today()`), analytics date bucketing, and rendered `datetime` instants all + * resolve against the org's region. + * + * Display formats follow the locale. How dates, times and numbers are written, + * and which day a week starts on, come from `locale` (language and region), the + * way Salesforce derives them from a user's Locale. Four separate settings for + * them (`date_format`, `time_format`, `number_format`, `first_day_of_week`) + * were offered here until #21958 and nothing ever read them; they were retired + * rather than implemented (the ruling recorded on objectui#11675). Removing + * them is why `version` is 2: the spec's `SettingsManifest.version` contract is + * "increment when keys are renamed/removed". A `sys_setting` row stored for one + * of them is kept, never deleted. The service no longer resolves it (it does + * not appear in `GET /api/settings/localization`), and a write naming the key + * is refused `400 UNKNOWN_KEY`, the answer every undeclared key gets. * * Scope is `tenant`: one org per physical tenant (ADR-0002) sets its regional * defaults; the manifest `default` of each key is the platform built-in, and a @@ -18,10 +31,10 @@ import type { SettingsManifest } from '@objectstack/spec/system'; */ export const localizationSettingsManifest: SettingsManifest = { namespace: 'localization', - version: 1, + version: 2, label: 'Localization', icon: 'Globe', - description: 'Default timezone, language, currency, and date/number formats.', + description: 'Default timezone, language, country, currency, and fiscal year.', scope: 'tenant', readPermission: 'setup.access', writePermission: 'setup.write', @@ -78,45 +91,6 @@ export const localizationSettingsManifest: SettingsManifest = { valueDomain: 'iso_3166_alpha2', }, - // ── Formats ─────────────────────────────────────────────────────────── - { type: 'group', id: 'formats', label: 'Formats', required: false }, - { - type: 'select', key: 'date_format', label: 'Date format', required: false, default: 'YYYY-MM-DD', - options: [ - { value: 'YYYY-MM-DD', label: '2026-06-17 (ISO)' }, - { value: 'MM/DD/YYYY', label: '06/17/2026 (US)' }, - { value: 'DD/MM/YYYY', label: '17/06/2026 (EU)' }, - { value: 'DD.MM.YYYY', label: '17.06.2026' }, - { value: 'DD-MMM-YYYY', label: '17-Jun-2026' }, - ], - }, - { - type: 'select', key: 'time_format', label: 'Time format', required: false, default: '24h', - options: [ - { value: '24h', label: '24-hour (14:30)' }, - { value: '12h', label: '12-hour (2:30 PM)' }, - ], - }, - { - type: 'select', key: 'number_format', label: 'Number format', required: false, default: '1,234.56', - description: 'Grouping and decimal separators for displayed numbers.', - options: [ - { value: '1,234.56', label: '1,234.56 (comma / dot)' }, - { value: '1.234,56', label: '1.234,56 (dot / comma)' }, - { value: '1 234,56', label: '1 234,56 (space / comma)' }, - { value: '1,23,456.78', label: '1,23,456.78 (Indian)' }, - ], - }, - { - type: 'select', key: 'first_day_of_week', label: 'First day of week', required: false, default: 'monday', - description: 'Anchors weekly analytics buckets and calendar grids.', - options: [ - { value: 'monday', label: 'Monday (ISO)' }, - { value: 'sunday', label: 'Sunday' }, - { value: 'saturday', label: 'Saturday' }, - ], - }, - // ── Finance ─────────────────────────────────────────────────────────── { type: 'group', id: 'finance', label: 'Finance', required: false }, { diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index b6da55d5a26..0fe08fb52f3 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -666,3 +666,98 @@ describe('settings-routes — #7522 encrypted values are redacted at the REST bo ); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #21958 — `date_format`, `time_format`, `number_format` and `first_day_of_week` +// left the Localization manifest (ruled on objectui#11675: formats follow the +// locale). These pin what the REST surface does with a value a workspace +// stored for one of them before the upgrade: +// +// - read: not offered and not resolved — absent from the served manifest +// (which is what the Setup page renders) and from `values`; +// - write: refused `400 UNKNOWN_KEY`, naming the key, like any undeclared key; +// - the stored row is KEPT: no read, write or reset path rewrites or deletes it, +// and it never blocks a save of the keys that remain. +// +// The rows are seeded store-level, the way `seedGlobalSecret` above seeds one: +// after the upgrade no public write path can produce them. +// ───────────────────────────────────────────────────────────────────────────── + +const RETIRED_FORMAT_ROWS: Record = { + date_format: 'DD.MM.YYYY', + time_format: '12h', + number_format: '1.234,56', + first_day_of_week: 'sunday', +}; + +async function makeRetiredFormatStack() { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + for (const [key, value] of Object.entries(RETIRED_FORMAT_ROWS)) { + await (svc as any).upsertRow({ + namespace: 'localization', key, scope: 'tenant', user_id: null, + value, value_enc: null, encrypted: false, + }); + } + const http = new MockHttp(); + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); + /** The store's own rows for the four keys, read the way every resolve reads them. */ + const storedFormatRows = async () => { + const rows = (await (svc as any).loadRows('localization', null)) as Array<{ key: string; value: unknown }>; + return Object.fromEntries(rows.filter((r) => r.key in RETIRED_FORMAT_ROWS).map((r) => [r.key, r.value])); + }; + return { svc, http, storedFormatRows }; +} + +describe('settings-routes — the four retired Localization format keys (#21958)', () => { + it('GET /api/settings/localization offers none of them, even with a stored value', async () => { + const { http, storedFormatRows } = await makeRetiredFormatStack(); + const { req, res, state } = makeReqRes({ params: { namespace: 'localization' } }); + await http.routes.get('GET /api/settings/:namespace')!(req, res); + + expect(state.status).toBe(200); + const specifiers = state.body.data.manifest.specifiers as Array<{ key?: string; id?: string }>; + const offered = specifiers.map((s) => s.key).filter(Boolean); + for (const key of Object.keys(RETIRED_FORMAT_ROWS)) { + expect(offered, `${key} must not be offered`).not.toContain(key); + expect(Object.keys(state.body.data.values), `${key} must not be resolved`).not.toContain(key); + } + expect(specifiers.map((s) => s.id)).not.toContain('formats'); + // The five live keys still answer. + expect(Object.keys(state.body.data.values).sort()).toEqual( + ['currency', 'default_country', 'fiscal_year_start', 'locale', 'timezone'], + ); + expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS); + }); + + it('PUT naming one of them is refused 400 UNKNOWN_KEY, and the stored row is untouched', async () => { + const { http, storedFormatRows } = await makeRetiredFormatStack(); + for (const key of Object.keys(RETIRED_FORMAT_ROWS)) { + const { req, res, state } = makeReqRes({ + params: { namespace: 'localization' }, + // A live key in the same batch: the refusal is whole-batch, nothing lands. + body: { [key]: 'anything', timezone: 'Europe/Paris' }, + }); + await http.routes.get('PUT /api/settings/:namespace')!(req, res); + expect(state.status).toBe(400); + expect(state.body.error.code).toBe('UNKNOWN_KEY'); + expect(state.body.error.details).toEqual({ namespace: 'localization', key }); + } + expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS); + }); + + it('a stored retired row never blocks saving the live keys, and reset leaves it in place', async () => { + const { svc, http, storedFormatRows } = await makeRetiredFormatStack(); + const put = makeReqRes({ params: { namespace: 'localization' }, body: { timezone: 'Europe/Paris' } }); + await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res); + expect(put.state.status).toBe(200); + expect(put.state.body.data.values.timezone.value).toBe('Europe/Paris'); + + const reset = makeReqRes({ params: { namespace: 'localization', actionId: 'reset' }, body: {} }); + await http.routes.get('POST /api/settings/:namespace/:actionId')!(reset.req, reset.res); + expect(reset.state.status).toBe(200); + expect((await svc.get('localization', 'timezone')).source).toBe('default'); + + expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 05478ebbfc4..4a6f8bf48ed 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -2200,7 +2200,8 @@ describe('SettingsService — a declared valueDomain is the save-time boundary ( // The registry-backed shape (`mail.provider`, `sms.provider`): its table // IS the supported set, and declaring no domain must keep it that way, // byte-for-byte. Pinned on the mail manifest itself plus a localization - // key that deliberately declares no domain. + // key that deliberately declares no domain (`fiscal_year_start`, a closed + // table of twelve months). const svc = new SettingsService({ env: {} }); svc.registerManifest(mailSettingsManifest); await expect(svc.setMany('mail', { provider: 'sendgrid' })).rejects.toMatchObject({ @@ -2209,9 +2210,9 @@ describe('SettingsService — a declared valueDomain is the save-time boundary ( }); const loc = localizationService(); - await expect(loc.setMany('localization', { first_day_of_week: 'thursday' })).rejects.toMatchObject({ + await expect(loc.setMany('localization', { fiscal_year_start: 'smarch' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION', - fields: [{ field: 'first_day_of_week', code: 'invalid_option' }], + fields: [{ field: 'fiscal_year_start', code: 'invalid_option' }], }); }); @@ -2387,11 +2388,11 @@ describe('SettingsService — env overrides are judged against the declared valu it('env door for a domain-less select keeps exhaustive options — the #5131/#5204 regression pin', async () => { const { errors, logger } = spyLogger(); - const svc = new SettingsService({ env: { OS_LOCALIZATION_DATE_FORMAT: 'DD>MM>YYYY' }, logger }); + const svc = new SettingsService({ env: { OS_LOCALIZATION_FISCAL_YEAR_START: 'smarch' }, logger }); svc.registerManifest(localizationSettingsManifest); - const r = await svc.get('localization', 'date_format'); - expect(r.value).toBe('YYYY-MM-DD'); // the default — the override is not in force + const r = await svc.get('localization', 'fiscal_year_start'); + expect(r.value).toBe('january'); // the default — the override is not in force expect(r.source).toBe('default'); expect(errors).toHaveLength(1); expect(errors[0]).toContain('is not a declared option for'); diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 1bd1b39f36b..0b0918e9ece 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -108,20 +108,15 @@ export const en: PlatformTranslationData = { localization: { title: 'Localization', - description: 'Default timezone, language, currency, and date/number formats.', + description: 'Default timezone, language, country, currency, and fiscal year.', groups: { region: { title: 'Region' }, - formats: { title: 'Formats' }, finance: { title: 'Finance' }, }, keys: { timezone: { label: 'Default timezone', help: 'IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes.' }, locale: { label: 'Default language', help: 'BCP-47 locale for message catalogs and number/date formatting.' }, default_country: { label: 'Default country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' }, - date_format: { label: 'Date format' }, - time_format: { label: 'Time format', options: { '24h': '24-hour (14:30)', '12h': '12-hour (2:30 PM)' } }, - number_format: { label: 'Number format' }, - first_day_of_week: { label: 'First day of week', options: { monday: 'Monday (ISO)', sunday: 'Sunday', saturday: 'Saturday' } }, currency: { label: 'Default currency' }, fiscal_year_start: { label: 'Fiscal year start' }, }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 03a4cc51e35..36fc24f007e 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -290,20 +290,15 @@ export const esES: PlatformTranslationData = { localization: { title: 'Localización', - description: 'Zona horaria, idioma, moneda y formatos de fecha/número predeterminados.', + description: 'Zona horaria, idioma, país, moneda y año fiscal predeterminados.', groups: { region: { title: 'Región' }, - formats: { title: 'Formatos' }, finance: { title: 'Finanzas' }, }, keys: { timezone: { label: 'Zona horaria predeterminada', help: 'Zona IANA usada para resolver today()/daysFromNow, los buckets de fecha de analítica y las fechas/horas renderizadas.' }, locale: { label: 'Idioma predeterminado', help: 'Configuración regional BCP-47 para los catálogos de mensajes y el formato de números/fechas.' }, default_country: { label: 'País predeterminado', help: 'Código ISO 3166-1 alfa-2 (p. ej. US, GB, CN). Se usa para los valores por defecto de dirección y teléfono.' }, - date_format: { label: 'Formato de fecha' }, - time_format: { label: 'Formato de hora' }, - number_format: { label: 'Formato de número', help: 'Separadores de miles y decimales para los números mostrados.' }, - first_day_of_week: { label: 'Primer día de la semana', help: 'Ancla los buckets de analítica semanal y las cuadrículas de calendario.' }, currency: { label: 'Moneda predeterminada', help: 'Código ISO 4217 aplicado cuando un campo de moneda no indica la suya.' }, fiscal_year_start: { label: 'Inicio del año fiscal', help: 'Primer mes del año fiscal: determina «este trimestre / año fiscal» en los informes.' }, }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index f6d65e60cfe..05f68412ae7 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -290,20 +290,15 @@ export const jaJP: PlatformTranslationData = { localization: { title: 'ローカリゼーション', - description: '既定のタイムゾーン、言語、通貨、日付/数値フォーマット。', + description: '既定のタイムゾーン、言語、国、通貨、会計年度。', groups: { region: { title: '地域' }, - formats: { title: 'フォーマット' }, finance: { title: '財務' }, }, keys: { timezone: { label: '既定のタイムゾーン', help: 'today()/daysFromNow の解決、分析の日付バケット、日時レンダリングに使う IANA タイムゾーン。' }, locale: { label: '既定の言語', help: 'メッセージカタログと数値/日付フォーマットに使う BCP-47 ロケール。' }, default_country: { label: '既定の国', help: 'ISO 3166-1 alpha-2 コード(例:US、GB、CN)。住所と電話の既定に使用します。' }, - date_format: { label: '日付フォーマット' }, - time_format: { label: '時刻フォーマット' }, - number_format: { label: '数値フォーマット', help: '表示する数値の桁区切りと小数点の記号。' }, - first_day_of_week: { label: '週の開始日', help: '週次分析のバケットとカレンダーグリッドの基準になります。' }, currency: { label: '既定の通貨', help: '通貨フィールドが独自の通貨を指定しない場合に適用される ISO 4217 コード。' }, fiscal_year_start: { label: '会計年度の開始月', help: '会計年度の最初の月 — レポートの「今四半期 / 会計年度」を決定します。' }, }, diff --git a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts index 6c127cb364a..b381c1d99ae 100644 --- a/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts +++ b/packages/services/service-settings/src/translations/settings-translation-coverage.test.ts @@ -12,15 +12,15 @@ * This test pins each shipped non-English locale to full coverage of every * manifest's structural strings — namespace title/description, each group * title, and each field label. Dropdown *option* labels are intentionally - * excluded: several are codes or format specimens (e.g. `date_format` = - * "YYYY-MM-DD", `number_format` = "1,234.56") that must not be "translated", - * and option-level parity is filled per-locale on a best-effort basis. + * excluded: several must not be "translated" (the `timezone` option "UTC" is a + * code; each `locale` option names its language in that language), and + * option-level parity is filled per-locale on a best-effort basis. */ import { describe, it, expect } from 'vitest'; import type { SettingsManifest } from '@objectstack/spec/system'; import * as manifestsModule from '../manifests/index.js'; -import { zhCN, jaJP, esES } from './index.js'; +import { en, zhCN, jaJP, esES } from './index.js'; // The manifests barrel also exports action handlers and the aggregate array; // keep only the manifest objects. @@ -53,6 +53,35 @@ function missingFor(data: { settings?: Record }, m: SettingsManifes return missing; } +/** + * The other direction: copy for a group or key no manifest declares. Such an + * entry renders nowhere, so it survives every review as dead text. The four + * Localization format keys retired by #21958 are the case that named it. + */ +function undeclaredIn(data: { settings?: Record }): string[] { + const byNamespace = new Map(manifests.map((m) => [m.namespace, m])); + const extra: string[] = []; + for (const [ns, tr] of Object.entries(data.settings ?? {})) { + const m = byNamespace.get(ns); + if (!m) { + extra.push(`namespace:${ns}`); + continue; + } + const groups = new Set(); + const keys = new Set(); + for (const s of m.specifiers) { + if (s.type === 'group') { + if (s.id) groups.add(s.id); + } else if (s.key) { + keys.add(s.key); + } + } + for (const g of Object.keys(tr?.groups ?? {})) if (!groups.has(g)) extra.push(`${ns}.group:${g}`); + for (const k of Object.keys(tr?.keys ?? {})) if (!keys.has(k)) extra.push(`${ns}.key:${k}`); + } + return extra; +} + describe('settings translation coverage', () => { it('covers every built-in settings manifest', () => { expect(manifests.length).toBeGreaterThanOrEqual(10); @@ -66,4 +95,11 @@ describe('settings translation coverage', () => { }); } } + + for (const [locale, data] of [['en', en], ...LOCALES] as typeof LOCALES) { + it(`${locale} carries no copy for a group or key no manifest declares`, () => { + const extra = undeclaredIn(data); + expect(extra, `${locale} has undeclared settings copy: ${extra.join(', ')}`).toEqual([]); + }); + } }); diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index efa89472678..8a4a47f311d 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -144,10 +144,9 @@ export const zhCN: PlatformTranslationData = { localization: { title: '本地化', - description: '默认时区、语言、货币及日期/数字格式。', + description: '默认时区、语言、国家/地区、货币及财年。', groups: { region: { title: '区域' }, - formats: { title: '格式' }, finance: { title: '财务' }, }, keys: { @@ -185,28 +184,6 @@ export const zhCN: PlatformTranslationData = { }, }, default_country: { label: '默认国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' }, - date_format: { - label: '日期格式', - options: { - 'YYYY-MM-DD': '2026-06-17(ISO)', - 'MM/DD/YYYY': '06/17/2026(美国)', - 'DD/MM/YYYY': '17/06/2026(欧洲)', - 'DD.MM.YYYY': '17.06.2026', - 'DD-MMM-YYYY': '17-Jun-2026', - }, - }, - time_format: { label: '时间格式', options: { '24h': '24 小时制(14:30)', '12h': '12 小时制(2:30 PM)' } }, - number_format: { - label: '数字格式', - help: '用于显示数字的千分位与小数分隔符。', - options: { - '1,234.56': '1,234.56(逗号 / 句点)', - '1.234,56': '1.234,56(句点 / 逗号)', - '1 234,56': '1 234,56(空格 / 逗号)', - '1,23,456.78': '1,23,456.78(印度)', - }, - }, - first_day_of_week: { label: '每周起始日', help: '用作周度分析分桶与日历网格的起始基准。', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } }, currency: { label: '默认货币', help: '当货币字段未指定币种时套用的 ISO 4217 代码。', From 397edabf048f3dab63dd651b77d28e00136dbaa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:50:54 +0000 Subject: [PATCH 2/2] chore(changeset): service-settings minor for the retired Localization format keys Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../21958-retire-locale-format-settings.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/21958-retire-locale-format-settings.md diff --git a/.changeset/21958-retire-locale-format-settings.md b/.changeset/21958-retire-locale-format-settings.md new file mode 100644 index 00000000000..2a4d49b6a9f --- /dev/null +++ b/.changeset/21958-retire-locale-format-settings.md @@ -0,0 +1,24 @@ +--- +'@objectstack/service-settings': minor +--- + +fix(service-settings)!: the Localization settings no longer offer `date_format`, `time_format`, `number_format` or `first_day_of_week`: dates, times, numbers and the week start follow the locale (#21958) + +Clause-②: no (narrowing) + + + +**BREAKING**: the Localization settings namespace (`localizationSettingsManifest`) drops four specifiers that nothing ever read: `date_format`, `time_format`, `number_format` and `first_day_of_week`, together with the Formats group they made up and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles. Setup → Localization no longer shows them, and `GET /api/settings/localization` no longer serves them. The maintainer ruled that dates, times, numbers and the first day of the week follow the user's locale (language and region), as Salesforce derives them from a Locale, and that the four separate settings retire rather than being implemented. `timezone`, `locale`, `default_country`, `currency` and `fiscal_year_start` are unchanged. The manifest's `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed. + +**A value a workspace already stored for one of the four is kept.** Measured at the REST surface: + +- The `sys_setting` row stays exactly as it was. No read, save or reset rewrites or deletes it. +- `GET /api/settings/localization` does not resolve it: the key is absent from both `manifest.specifiers` and `values`. +- A `PUT /api/settings/localization` that names one of the four is refused with `400 UNKNOWN_KEY` (`details.key` names it), the answer every undeclared key gets. The refusal covers the whole batch, so a live key sent beside it does not land either. +- A stored row never blocks saving the live keys, and the built-in `reset` action leaves it in place. +- In process, `settings.get('localization', 'date_format')` (or any of the four) now rejects with `SETTINGS_UNKNOWN_KEY`. +- An `OS_LOCALIZATION_DATE_FORMAT`, `OS_LOCALIZATION_TIME_FORMAT`, `OS_LOCALIZATION_NUMBER_FORMAT` or `OS_LOCALIZATION_FIRST_DAY_OF_WEEK` variable is no longer read. It set a value that nothing read before either. + +**What to do after upgrading.** Nothing has to be rewritten: the four keys never changed how anything rendered. There is no replacement key. `locale`, the workspace's language and region, is what decides how dates, times and numbers are written. The console's calendar and timeline do not yet take their first day of the week from it; that is objectui work, tracked and shipped separately. + +It ships as `minor` under the launch-window convention for narrowings.