diff --git a/.changeset/22136-settings-timezone-help-plain.md b/.changeset/22136-settings-timezone-help-plain.md new file mode 100644 index 00000000000..0dbc81ea79a --- /dev/null +++ b/.changeset/22136-settings-timezone-help-plain.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-settings": patch +--- + +fix(service-settings): the Default timezone help speaks to administrators, not in formula-function names + +Clause-②: no + +The help under Settings → Localization → Default timezone (also shown in the first-run "set the workspace timezone" prompt) read "IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes." It now says what the setting does: "Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris)." The same meaning ships in every built-in locale (en, zh-CN, ja-JP, es-ES) and in the manifest's own `description`, which `GET /api/settings/localization` serves and the console falls back to. + +Wording only: the `timezone` key, its default (`UTC`), its accepted values (any IANA zone) and what it drives are unchanged. diff --git a/packages/services/service-settings/src/localization-timezone-help.pin.test.ts b/packages/services/service-settings/src/localization-timezone-help.pin.test.ts new file mode 100644 index 00000000000..ed8d1573598 --- /dev/null +++ b/packages/services/service-settings/src/localization-timezone-help.pin.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Default timezone help, as the platform SERVES it, is written for an + * administrator: in every locale it names no formula-function identifier. + * + * An administrator meets this text under the timezone field in Settings → + * Localization and in the first-run "set the workspace timezone" prompt. The + * console renders `settings.localization.keys.timezone.help` from the bundle + * this plugin loads into the i18n service, and falls back to the manifest's + * `description` (served by `GET /api/settings/localization`) for a locale + * with no entry. So this pin reads those two served layers, never the source + * files: + * + * 1. what `SettingsServicePlugin` hands `i18n.loadTranslations` at + * `kernel:ready`, per locale, on a real `LiteKernel` boot; + * 2. the manifest as the settings route answers it, from the service that + * boot registered. + * + * "Every locale" is whatever the plugin loads, so a locale added later is held + * to this without an edit here; the floor below only stops the loop from + * passing over nothing. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsServicePlugin } from './settings-service-plugin.js'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; + +const NAMESPACE = 'localization'; +const KEY = 'timezone'; + +/** The locales the service ships today — a floor, not the population. */ +const SHIPPED_LOCALES = ['en', 'zh-CN', 'ja-JP', 'es-ES']; + +/** + * A function identifier as a formula writes it: a name directly followed by + * its opening parenthesis (`today(`), or a lowerCamelCase name (`daysFromNow`). + * Prose never needs either; a parenthetical after a space (`name (e.g. …)`) + * is not a call and does not match. + */ +const CALL_SHAPE = /[A-Za-z_$][A-Za-z0-9_$]*\(/; +const LOWER_CAMEL = /\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/; + +function functionIdentifiersIn(text: string): string[] { + const found: string[] = []; + const call = text.match(CALL_SHAPE); + if (call) found.push(call[0]); + const camel = text.match(LOWER_CAMEL); + if (camel) found.push(camel[0]); + return found; +} + +/** Registers an `i18n` service that records every bundle it is handed. */ +class I18nCapturePlugin implements Plugin { + name = 'test.i18n-capture'; + version = '0.0.0'; + readonly loaded = new Map>(); + async init(ctx: PluginContext): Promise { + ctx.registerService('i18n', { + loadTranslations: (locale: string, data: Record) => { + this.loaded.set(locale, data); + }, + }); + } +} + +class MockHttp implements IHttpServer { + routes = new Map(); + get(path: string, h: RouteHandler) { this.routes.set(`GET ${path}`, h); return this as any; } + post(path: string, h: RouteHandler) { this.routes.set(`POST ${path}`, h); return this as any; } + put(path: string, h: RouteHandler) { this.routes.set(`PUT ${path}`, h); return this as any; } + delete(path: string, h: RouteHandler) { this.routes.set(`DELETE ${path}`, h); return this as any; } + patch(path: string, h: RouteHandler) { this.routes.set(`PATCH ${path}`, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +let kernel: LiteKernel; +let capture: I18nCapturePlugin; +let servedDescription: unknown; + +beforeAll(async () => { + capture = new I18nCapturePlugin(); + kernel = new LiteKernel({ logger: { level: 'error' } as never }); + // The plugin's own defaults: the bundled manifests and translations, exactly + // as a host that mounts it without options serves them. + kernel.use(capture); + kernel.use(new SettingsServicePlugin({ registerRoutes: false, env: {} })); + await kernel.bootstrap(); + + // The route the console's Localization page and the prompt read, mounted on + // the service that boot registered, for an administrator holding the + // namespace's read capability. + const http = new MockHttp(); + registerSettingsRoutes(http, kernel.getService('settings'), { + contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'] }), + }); + const state: { status: number; body?: any } = { status: 200 }; + const res = { + json: vi.fn((data: unknown) => { state.body = data; }), + send: vi.fn(), + status: vi.fn((code: number) => { state.status = code; return res; }), + header: vi.fn(() => res), + } as unknown as IHttpResponse; + await http.routes.get('GET /api/settings/:namespace')!( + { params: { namespace: NAMESPACE }, query: {}, headers: {}, method: 'GET', path: `/api/settings/${NAMESPACE}` } as IHttpRequest, + res, + ); + expect(state.status).toBe(200); + const specifiers = (state.body?.data?.manifest?.specifiers ?? []) as Array<{ key?: string; description?: unknown }>; + servedDescription = specifiers.find((s) => s.key === KEY)?.description; +}); + +afterAll(async () => { + await kernel?.shutdown(); +}); + +describe('the served Default timezone help names no function identifier', () => { + it('the detector flags the developer wording it exists to keep out', () => { + // Without this a detector that matches nothing passes every case below. + expect(functionIdentifiersIn('IANA zone for today()/daysFromNow, analytics date buckets.')) + .toEqual(['today(', 'daysFromNow']); + }); + + it('the plugin loads a bundle for every shipped locale', () => { + expect([...capture.loaded.keys()]).toEqual(expect.arrayContaining(SHIPPED_LOCALES)); + }); + + it('the manifest description served by GET /api/settings/localization', () => { + expect(typeof servedDescription).toBe('string'); + expect((servedDescription as string).trim()).not.toBe(''); + expect(functionIdentifiersIn(servedDescription as string)).toEqual([]); + }); + + it('the translated help, in every locale the plugin loads', () => { + expect(capture.loaded.size).toBeGreaterThanOrEqual(SHIPPED_LOCALES.length); + for (const [locale, bundle] of capture.loaded) { + const help = bundle?.settings?.[NAMESPACE]?.keys?.[KEY]?.help; + // Present in every locale: a dropped entry would fall back to the + // manifest literal and turn this loop into a pass over nothing. + expect(typeof help, `${locale}: settings.${NAMESPACE}.keys.${KEY}.help`).toBe('string'); + expect(functionIdentifiersIn(help), `${locale}: ${help}`).toEqual([]); + } + }); +}); diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index e8f3039af91..e9af7ed9ade 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -45,7 +45,11 @@ export const localizationSettingsManifest: SettingsManifest = { { type: 'group', id: 'region', label: 'Region', required: false }, { type: 'select', key: 'timezone', label: 'Default timezone', required: false, default: 'UTC', - description: 'IANA zone used to resolve today()/daysFromNow, analytics date buckets, and rendered datetimes.', + // Written for the administrator who reads it under the field (Settings → + // Localization and the first-run timezone prompt), so it names effects, + // not formula functions; `localization-timezone-help.pin.test.ts` holds + // every served locale to that. + description: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris).', // The description has always promised the IANA domain; since #5712 the // declaration matches it: any valid IANA zone is accepted on the write // and env doors, and the curated options below are a UI convenience diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index 0b0918e9ece..f6c8feddcba 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -114,7 +114,7 @@ export const en: PlatformTranslationData = { finance: { title: 'Finance' }, }, keys: { - timezone: { label: 'Default timezone', help: 'IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes.' }, + timezone: { label: 'Default timezone', help: 'Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris).' }, 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).' }, currency: { label: 'Default currency' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 36fc24f007e..541893918b8 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -296,7 +296,7 @@ export const esES: PlatformTranslationData = { 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.' }, + timezone: { label: 'Zona horaria predeterminada', help: 'Determina qué significa «hoy» en las fórmulas, cómo agrupan las fechas los informes y cómo se muestran las fechas y horas. Acepta cualquier nombre de zona horaria IANA (p. ej. Europe/Madrid).' }, 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.' }, currency: { label: 'Moneda predeterminada', help: 'Código ISO 4217 aplicado cuando un campo de moneda no indica la suya.' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 05f68412ae7..a3fc0989896 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -296,7 +296,7 @@ export const jaJP: PlatformTranslationData = { finance: { title: '財務' }, }, keys: { - timezone: { label: '既定のタイムゾーン', help: 'today()/daysFromNow の解決、分析の日付バケット、日時レンダリングに使う IANA タイムゾーン。' }, + timezone: { label: '既定のタイムゾーン', help: '数式での「今日」がどの日か、レポートでの日付のまとめ方、日付と時刻の表示方法を決めます。任意の IANA タイムゾーン名(例:Asia/Tokyo)を指定できます。' }, locale: { label: '既定の言語', help: 'メッセージカタログと数値/日付フォーマットに使う BCP-47 ロケール。' }, default_country: { label: '既定の国', help: 'ISO 3166-1 alpha-2 コード(例:US、GB、CN)。住所と電話の既定に使用します。' }, currency: { label: '既定の通貨', help: '通貨フィールドが独自の通貨を指定しない場合に適用される ISO 4217 コード。' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 8a4a47f311d..0ffa332cfde 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -152,7 +152,7 @@ export const zhCN: PlatformTranslationData = { keys: { timezone: { label: '默认时区', - help: '用于 today()/daysFromNow、分析日期分桶和 datetime 渲染的 IANA 时区。', + help: '决定公式中的"今天"是哪一天、报表如何按日期分组,以及日期和时间如何显示。可填写任意 IANA 时区名称(如 Asia/Shanghai)。', options: { UTC: 'UTC', 'America/Los_Angeles': '(UTC−08/−07) 洛杉矶',