Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/22136-settings-timezone-help-plain.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, any>>();
async init(ctx: PluginContext): Promise<void> {
ctx.registerService('i18n', {
loadTranslations: (locale: string, data: Record<string, any>) => {
this.loaded.set(locale, data);
},
});
}
}

class MockHttp implements IHttpServer {
routes = new Map<string, RouteHandler>();
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<SettingsService>('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([]);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-settings/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 コード。' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) 洛杉矶',
Expand Down
Loading