Skip to content

Commit a675b4d

Browse files
hotlongclaude
andauthored
fix(platform-objects): restore the declared title on the System Overview by-action table and pin the default-locale bundle to the source (#8767)
* fix(platform-objects): restore the declared title on the System Overview by-action table and pin the default-locale bundle to the source (#8721) `widget_recent_events` became an ADR-0021 dataset-bound breakdown of audit events by action, but all four hand-authored locale bundles kept the pre-conversion title (`Recent Audit Events` and its three translations). The translation is what renders, so the declared string reached nobody in any locale; the widget's `description` had drifted the same way. Restoring the source string exposed the duplicate it was masking: the Row 3 pie (`widget_events_by_type`) and this table both declared `Audit Events by Action` over the same dataset and dimension. The pie keeps the broad name (the share picture); the table becomes `Event Volume by Action` — the exact per-action count its `values: ['event_count']` produces, which is what its description already said. Widget ids are untouched, so no translation key or persisted widget state moves. - correct `widget_recent_events` title + description in en / zh-CN / ja-JP / es-ES (leaf values only — this half is hand-authored and regenerating it would delete the runtime-contributed nav translations) - add the default-locale content-parity assertion to `app-nav-translation-parity.test.ts`: every declared app label/description, statically declared nav label, dashboard label/description and widget title/description must appear in `en.ts` verbatim. Every pre-existing claim in that file is a key-set claim, which a stale value satisfies. - stale-marking for TRANSLATED locales when a source string changes is deliberately not designed here — it needs a product decision on what a stale translation should do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 * test(platform-objects): point the two deliberate parity gaps at their tracking issues (#8721) The default-locale content check documents two gaps it does not close: the `pages.*` section (sources live in @objectstack/cloud-connection and @objectstack/mcp, which this package must not import) and the translated locales (what a stale translation should do is a product decision). Both were argued in the comments and tracked nowhere. - `pages.*` gap -> #8764, with the note that it is unguarded in EVERY locale including `en`, and that `check:app-nav-i18n` is the gate that can see it - translated-locale gap -> #8765, with the consequence pinning `en` has on it: the drift stops being uniform across four bundles and becomes locale-specific Comment-only; no assertion changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 693c788 commit a675b4d

7 files changed

Lines changed: 210 additions & 16 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721)
6+
7+
`widget_recent_events` was converted into an ADR-0021 single-form — a
8+
dataset-bound breakdown of `sys_audit_log` events by action — but all four
9+
hand-authored locale bundles kept serving the title the widget had *before* the
10+
conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` /
11+
`Eventos de Auditoría Recientes`). The translation is what renders, so the
12+
declared string reached nobody in any locale. Its `description` had drifted the
13+
same way and in the same direction, one field over.
14+
15+
**The duplicate the stale translation was hiding.** With the source string
16+
restored, the board carried the same label twice: `widget_events_by_type` (a
17+
pie) and `widget_recent_events` (a table) both declared `Audit Events by
18+
Action`, over the same dataset and the same dimension. They looked distinct in a
19+
running instance only because one of them was serving a stale translation. The
20+
pair now splits on what each adds — the pie keeps `Audit Events by Action` (the
21+
share picture), the table becomes **`Event Volume by Action`** (the exact
22+
per-action count, which is what its `values: ['event_count']` produces and what
23+
its description already said). All four locales are translated to the new
24+
strings; the widget **ids are unchanged**, so no translation key, persisted
25+
widget state or dataset binding moves.
26+
27+
**Why nothing caught it, and what now does.** This package's `apps` /
28+
`dashboards` / `pages` i18n is hand-authored and cannot be regenerated —
29+
regenerating would delete ~40 runtime-contributed nav translations per locale —
30+
so it never had the source-tracking the generated half gets from the extractor.
31+
Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts`
32+
asserts a translation exists and does not outlive its declaration;
33+
`check:i18n-coverage` ratchets *untranslated* labels; `check:app-nav-i18n` judges
34+
the merged nav tree), and a key whose value is stale satisfies all of them.
35+
36+
`app-nav-translation-parity.test.ts` now also asserts the **default locale's
37+
content**: every statically declared app label, description and nav label, plus
38+
the dashboard's label, description and every widget title/description, must
39+
appear in `en.ts` **verbatim**. That claim is available for `en` alone because
40+
`en` is a copy of the source rather than a translation of it — the same
41+
invariant the generated half already enforces by rewriting its `en` bundle on
42+
every extract. What a *translated* locale should do when its source string
43+
changes is a separate product decision and is deliberately not decided here.

packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Dashboard } from '@objectstack/spec/ui';
1515
* 1. Platform KPIs — users / orgs / sessions / packages
1616
* 2. Security KPIs — login / config audit counts
1717
* 3. Distribution charts — audit events by action + by user
18-
* 4. Recent audit events table
18+
* 4. Event volume by action (table)
1919
*
2020
* This is a MIXED board, and the split decides who the date bar applies to
2121
* (#7531, #7613). Row 1 is INVENTORY — "how much of this exists right now" —
@@ -201,13 +201,27 @@ export const SystemOverviewDashboard = Dashboard.create({
201201
layout: { x: 6, y: 4, w: 6, h: 4 },
202202
},
203203

204-
// ── Row 4: Audit events by action ───────────────────────────────
204+
// ── Row 4: Event volume by action ───────────────────────────────
205205
// ADR-0021 single-form: a dataset-bound breakdown of events by action.
206206
// (The raw recent-events record list belongs in a ListView on
207207
// sys_audit_log — a row-level lens, not a dashboard analytics widget.)
208+
//
209+
// The title says "volume", not "events by action", because the Row 3 pie
210+
// (`widget_events_by_type`) breaks down the SAME dataset by the SAME
211+
// dimension and had the identical title until now — two tiles on one board
212+
// labelled `Audit Events by Action`. They read as distinct in a running
213+
// instance only because this one was serving a stale translation from
214+
// before the ADR-0021 conversion, so the duplicate was invisible in the UI
215+
// and visible only in the source. The pair now splits on what each adds:
216+
// the pie is the share picture, this table is the exact per-action count
217+
// (`values: ['event_count']`).
218+
//
219+
// The id stays `widget_recent_events` deliberately — it predates the
220+
// conversion, and renaming it would break every locale bundle's key and
221+
// any persisted per-widget state for a cosmetic gain.
208222
{
209223
id: 'widget_recent_events',
210-
title: 'Audit Events by Action',
224+
title: 'Event Volume by Action',
211225
// The example actions named here have to be actions the platform can
212226
// actually emit — this string used to lead with `permission`, which
213227
// advertised the retired value from a second place on the same board.

packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts

Lines changed: 145 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
// complete and the ratchet at 0, that tool now reports the truth; this test is
2121
// the local, CLI-independent version of the same invariant.
2222
//
23-
// Setup is deliberately NOT covered here: its nav ids do not exist on the app
24-
// object at all until the runtime merges contributions in, so this file would
25-
// have nothing to walk.
23+
// Setup's nav LEAVES are deliberately NOT covered by the presence direction
24+
// below: they do not exist on the app object at all until the runtime merges
25+
// contributions in, so this file would have nothing to walk. (Its nine static
26+
// group anchors do exist statically, and the default-locale content check at
27+
// the bottom of this file walks exactly those — see that block's own note for
28+
// why a content claim can be made where a coverage claim cannot.)
2629
//
2730
// Where they ARE covered: `pnpm check:app-nav-i18n`
2831
// (`packages/cli/scripts/check-app-nav-i18n.mjs`), which boots the real
@@ -42,6 +45,7 @@
4245
import { describe, it, expect } from 'vitest';
4346
import { STUDIO_APP } from '../studio.app.js';
4447
import { ACCOUNT_APP } from '../account.app.js';
48+
import { SETUP_APP } from '../setup.app.js';
4549
import { SystemOverviewDashboard } from '../dashboards/index.js';
4650
import { en } from './en.js';
4751
import { zhCN } from './zh-CN.js';
@@ -50,20 +54,25 @@ import { esES } from './es-ES.js';
5054

5155
const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const;
5256

53-
/** Every nav id in an app's statically declared navigation tree, depth-first. */
54-
function navIds(app: { navigation?: unknown[] }): string[] {
55-
const out: string[] = [];
57+
/** Every statically declared nav item of an app, depth-first, with its label. */
58+
function navItems(app: { navigation?: unknown[] }): Array<{ id: string; label?: string }> {
59+
const out: Array<{ id: string; label?: string }> = [];
5660
const walk = (items: unknown[]) => {
5761
for (const raw of items ?? []) {
58-
const item = raw as { id?: string; children?: unknown[] };
59-
if (item?.id) out.push(item.id);
62+
const item = raw as { id?: string; label?: string; children?: unknown[] };
63+
if (item?.id) out.push({ id: item.id, label: item.label });
6064
if (Array.isArray(item?.children)) walk(item.children);
6165
}
6266
};
6367
walk(app.navigation ?? []);
6468
return out;
6569
}
6670

71+
/** Every nav id in an app's statically declared navigation tree, depth-first. */
72+
function navIds(app: { navigation?: unknown[] }): string[] {
73+
return navItems(app).map((item) => item.id);
74+
}
75+
6776
describe('statically declared app navigation is translated in every locale', () => {
6877
for (const app of [STUDIO_APP, ACCOUNT_APP] as Array<{ name: string; navigation?: unknown[] }>) {
6978
for (const [locale, data] of Object.entries(LOCALES)) {
@@ -135,3 +144,131 @@ describe('dashboard widgets are translated in every locale', () => {
135144
});
136145
}
137146
});
147+
148+
// ── The default locale serves the SOURCE string, not an old copy of it ───────
149+
//
150+
// Every claim above is a key-set claim: it judges whether a key exists on one
151+
// side or both. A key whose VALUE has gone stale satisfies all of them, and one
152+
// did — `widget_recent_events` kept `Recent Audit Events` in all four bundles
153+
// after the widget was converted into an ADR-0021 by-action breakdown whose
154+
// declared title says so. Since the translation is what renders, the declared
155+
// string reached nobody in any locale, under a fully green build.
156+
//
157+
// What can be asserted mechanically is the DEFAULT locale, because `en.ts` is a
158+
// copy of the source rather than a translation of it. That is the same
159+
// invariant the generated half of this package's i18n already enforces by
160+
// rewriting the `en` bundle from the source on every extract (see
161+
// `scripts/i18n-extract.config.ts`); this half is hand-authored and cannot be
162+
// regenerated — regenerating it would delete ~40 runtime-contributed nav
163+
// translations per locale — so the invariant is asserted here instead of being
164+
// produced by a generator.
165+
//
166+
// Deliberately NOT claimed here: anything about zh-CN / ja-JP / es-ES. What a
167+
// translated locale should do when its source string changes (keep serving the
168+
// stale value, fall back to the source, fail the build) is a product decision,
169+
// not a test's to invent. This block is the half that needs no decision; the
170+
// half that does is #8765, and note what pinning `en` does to it — the drift
171+
// stops being uniform across all four bundles and becomes locale-specific,
172+
// invisible to every reviewer who reads the product in English.
173+
//
174+
// Direction: source ⇒ en, one-way. A key in `en.ts` with no declaring source is
175+
// NOT judged — that set is exactly Setup's runtime-contributed nav leaves,
176+
// which no static walk can see and which `pnpm check:app-nav-i18n` and
177+
// `setup-nav-dead-key-tombstone.test.ts` own. Setup's nine static group anchors
178+
// ARE walked: a coverage claim over Setup is impossible here (most of its ids
179+
// are absent at import time), but a content claim over the few it does declare
180+
// is sound — the walk judges what it finds, and finds nothing it cannot judge.
181+
//
182+
// `pages.*` is out of the walk on purpose: those entries mirror page metadata
183+
// authored in OTHER packages (@objectstack/cloud-connection, @objectstack/mcp),
184+
// which this package does not import and must not depend on to run its tests.
185+
// That leaves the third of this bundle with no source comparison in ANY locale,
186+
// `en` included — the same shape as the defect above, one section over, and a
187+
// static walk in this package cannot close it. Tracked as #8764; the gate that
188+
// can see those pages is `check:app-nav-i18n`, which already boots the real
189+
// composition. All three were in parity when this block was written.
190+
describe('the default locale bundle serves the declared source string verbatim', () => {
191+
type Drift = { path: string; source: string; en: string | undefined };
192+
193+
const collect = (
194+
drift: Drift[],
195+
path: string,
196+
source: string | undefined,
197+
served: string | undefined,
198+
) => {
199+
// An undeclared source string makes no claim — only a declared one does.
200+
if (typeof source !== 'string') return;
201+
if (served !== source) drift.push({ path, source, en: served });
202+
};
203+
204+
const APPS = [SETUP_APP, STUDIO_APP, ACCOUNT_APP] as unknown as Array<{
205+
name: string;
206+
label?: string;
207+
description?: string;
208+
navigation?: unknown[];
209+
}>;
210+
211+
for (const app of APPS) {
212+
it(`apps.${app.name} — label, description and every statically declared nav label`, () => {
213+
const served = (en.apps?.[app.name] ?? {}) as {
214+
label?: string;
215+
description?: string;
216+
navigation?: Record<string, { label?: string }>;
217+
};
218+
const drift: Drift[] = [];
219+
collect(drift, `apps.${app.name}.label`, app.label, served.label);
220+
collect(drift, `apps.${app.name}.description`, app.description, served.description);
221+
for (const item of navItems(app)) {
222+
collect(
223+
drift,
224+
`apps.${app.name}.navigation.${item.id}.label`,
225+
item.label,
226+
served.navigation?.[item.id]?.label,
227+
);
228+
}
229+
expect(
230+
drift,
231+
`en.ts no longer matches the declared source in apps.${app.name} — `
232+
+ 'edit the bundle to the source string (this half is hand-authored; do NOT regenerate it)',
233+
).toEqual([]);
234+
});
235+
}
236+
237+
it('dashboards.system_overview — label, description and every widget title/description', () => {
238+
const dashboard = SystemOverviewDashboard as unknown as {
239+
name: string;
240+
label?: string;
241+
description?: string;
242+
widgets?: Array<{ id?: string; title?: string; description?: string }>;
243+
};
244+
const served = (en.dashboards?.[dashboard.name] ?? {}) as {
245+
label?: string;
246+
description?: string;
247+
widgets?: Record<string, { title?: string; description?: string }>;
248+
};
249+
const drift: Drift[] = [];
250+
collect(drift, `dashboards.${dashboard.name}.label`, dashboard.label, served.label);
251+
collect(
252+
drift,
253+
`dashboards.${dashboard.name}.description`,
254+
dashboard.description,
255+
served.description,
256+
);
257+
for (const widget of dashboard.widgets ?? []) {
258+
if (!widget.id) continue;
259+
const base = `dashboards.${dashboard.name}.widgets.${widget.id}`;
260+
collect(drift, `${base}.title`, widget.title, served.widgets?.[widget.id]?.title);
261+
collect(
262+
drift,
263+
`${base}.description`,
264+
widget.description,
265+
served.widgets?.[widget.id]?.description,
266+
);
267+
}
268+
expect(
269+
drift,
270+
'en.ts no longer matches the declared source in dashboards.system_overview — '
271+
+ 'edit the bundle to the source string (this half is hand-authored; do NOT regenerate it)',
272+
).toEqual([]);
273+
});
274+
});

packages/platform-objects/src/apps/translations/en.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ export const en: TranslationData = {
224224
description: 'Activity distribution across users',
225225
},
226226
widget_recent_events: {
227-
title: 'Recent Audit Events',
228-
description: 'Latest platform events (login, logout, config, …)',
227+
title: 'Event Volume by Action',
228+
description: 'Event volume grouped by action (login, logout, config, …)',
229229
},
230230
},
231231
},

packages/platform-objects/src/apps/translations/es-ES.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export const esES: TranslationData = {
151151
widget_config_changes: { title: 'Cambios de Configuración', description: 'Modificaciones de configuración del sistema' },
152152
widget_events_by_type: { title: 'Eventos de Auditoría por Acción', description: 'Distribución de eventos de auditoría por tipo de acción' },
153153
widget_events_by_user: { title: 'Eventos por Usuario', description: 'Distribución de actividad entre usuarios' },
154-
widget_recent_events: { title: 'Eventos de Auditoría Recientes', description: 'Últimos eventos de la plataforma (inicio de sesión, cierre de sesión, configuración, …)' },
154+
widget_recent_events: { title: 'Volumen de Eventos por Acción', description: 'Volumen de eventos agrupado por acción (inicio de sesión, cierre de sesión, configuración, …)' },
155155
},
156156
},
157157
},

packages/platform-objects/src/apps/translations/ja-JP.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export const jaJP: TranslationData = {
151151
widget_config_changes: { title: '構成変更', description: 'システム構成の変更' },
152152
widget_events_by_type: { title: 'アクション別監査イベント', description: 'アクションタイプ別の監査イベント分布' },
153153
widget_events_by_user: { title: 'ユーザー別イベント', description: 'ユーザー別アクティビティ分布' },
154-
widget_recent_events: { title: '最近の監査イベント', description: '最新のプラットフォームイベント(ログイン、ログアウト、構成など)' },
154+
widget_recent_events: { title: 'アクション別イベント件数', description: 'アクション別にグループ化されたイベント件数(ログイン、ログアウト、構成など)' },
155155
},
156156
},
157157
},

packages/platform-objects/src/apps/translations/zh-CN.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export const zhCN: TranslationData = {
161161
widget_config_changes: { title: '配置变更', description: '系统配置修改' },
162162
widget_events_by_type: { title: '按操作分布的审计事件', description: '审计事件按操作类型分布' },
163163
widget_events_by_user: { title: '按用户分布的事件', description: '用户活动分布' },
164-
widget_recent_events: { title: '最近审计事件', description: '最新的平台事件(登录、登出、配置等)' },
164+
widget_recent_events: { title: '按操作统计的事件量', description: '按操作分组的事件量(登录、登出、配置等)' },
165165
},
166166
},
167167
},

0 commit comments

Comments
 (0)