Update Owliver language and suggestions

This commit is contained in:
2026-10-07 19:15:44 +05:30
parent 8ac4e1ee88
commit 81e5979f8f
156 changed files with 7330 additions and 1755 deletions

58
scripts/i18n-audit.mjs Normal file
View File

@@ -0,0 +1,58 @@
/**
* Every t('key') in the source, checked against the catalogues.
*
* A key the code calls and the catalogue lacks renders as humanised English at
* best, so this is the check that says the extraction is actually complete.
* The reverse — a catalogue entry nothing calls — is only waste, and reported
* separately.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const walk = (dir, acc = []) => {
for (const e of readdirSync(dir)) {
if (e === 'node_modules' || e.startsWith('.')) continue;
const p = join(dir, e);
if (statSync(p).isDirectory()) walk(p, acc);
else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p);
}
return acc;
};
const flat = (obj, prefix = '', out = {}) => {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object') flat(v, key, out);
else out[key] = v;
}
return out;
};
const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8')));
const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8')));
const used = new Set();
const dynamic = [];
for (const file of walk('src')) {
const src = readFileSync(file, 'utf8');
for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]);
for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`);
}
/* A plural key is stored as name_one / name_other, so the base name is never
itself in the catalogue and is not missing. */
const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en;
const missingEn = [...used].filter((k) => !present(k)).sort();
const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort();
const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort();
console.log(`\n t() calls with a literal key : ${used.size}`);
console.log(` built from a template : ${dynamic.length} (checked by hand)`);
console.log(` MISSING from en.json : ${missingEn.length}`);
console.log(` MISSING from es.json : ${missingEs.length}`);
console.log(` in en.json, never called : ${unused.length}`);
for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`);
for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`);
if (missingEn.length) process.exitCode = 1;

126
scripts/i18n-coverage.mjs Normal file
View File

@@ -0,0 +1,126 @@
/**
* How much of each page is actually translated.
*
* Renders every migrated page twice — once in English, once in Spanish — and
* reports the text that did NOT change. That residue is the untranslated part,
* and it is the only honest measure of coverage: a catalogue can be full of
* Spanish that no component ever calls, and counting keys would score that as
* finished.
*
* Reports rather than asserts. The point is a number per page while the work is
* half done; `skill-check.mjs` is where a finished page gets a check that keeps
* it finished.
*
* node scripts/i18n-coverage.mjs
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createServer } from 'vite';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { withSourceResolution } from './ssr-resolve.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/* The pages reach for `window` on the way up — a media query, a resize
listener. The same minimal stub the skill-check uses, installed before any
page module is loaded. */
if (!('window' in globalThis)) {
globalThis.window = {
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
addEventListener() {}, removeEventListener() {},
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
}
const server = await createServer({
root: ROOT,
server: { middlewareMode: true },
appType: 'custom',
logLevel: 'error',
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
});
withSourceResolution(server);
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
const { MemoryRouter } = await import('react-router-dom');
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
const PAGES = [
['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'],
['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'],
['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'],
];
/* Text only, split into phrases on the punctuation the interface uses to
separate one label from the next, so "17 total" counts separately from the
heading above it. Numbers and symbols alone are not evidence either way —
"27" is the same in both languages — so anything without a letter is
dropped rather than scored as translated. */
const phrases = (html) =>
html
.replace(/<[^>]*>/g, '\n')
.split(/[\n·|•]+/)
.map((p) => p.replace(/\s+/g, ' ').trim())
.filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p));
async function render(path, page) {
const Page = (await server.ssrLoadModule(path)).default;
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
return renderToStaticMarkup(
React.createElement(MemoryRouter, { initialEntries: [page.route] },
React.createElement(QueryClientProvider, { client },
React.createElement(UiEditingProvider, { page: page.key },
React.createElement(Page))))
);
}
console.log('\n Translation coverage — text that changes when the language does\n');
let totalSame = 0;
let totalAll = 0;
for (const [name, path, route, key] of PAGES) {
let en;
let es;
try {
await i18n.changeLanguage('en');
en = phrases(await render(path, { route, key }));
await i18n.changeLanguage('es');
es = phrases(await render(path, { route, key }));
await i18n.changeLanguage('en');
} catch (err) {
console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`);
continue;
}
/* A phrase present in BOTH renders is one the switch did not touch. Proper
nouns and figures land here too, which is why the sample below is printed:
a human reads it and sees at once whether the residue is "KROW Forge" or a
whole untranslated paragraph. */
const esSet = new Set(es);
const untouched = en.filter((p) => esSet.has(p));
totalSame += untouched.length;
totalAll += en.length;
const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0;
const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·');
console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`);
if (untouched.length) {
const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4);
if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`);
}
}
const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0;
console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`);
await server.close();

307
scripts/i18n-extract.mjs Normal file
View File

@@ -0,0 +1,307 @@
/**
* Lift hard-coded English out of components and into the catalogue.
*
* node scripts/i18n-extract.mjs --dry src/components/krow
* node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx
*
* An AST codemod rather than a regex pass, because the hard part is not finding
* the string — it is knowing which component encloses it, so `const { t } =
* useTranslation()` lands in the right function and not in a module-level
* constant where hooks cannot run.
*
* WHAT IT WILL TOUCH
* - JSX text children that are entirely static words
* - string literals on a short list of text-bearing attributes
*
* WHAT IT LEAVES ALONE, and why each one would be a bug:
* - anything inside a module-level object or array; those are built once at
* import, so a t() there freezes the first language the app loaded
* - strings with no letters, single characters, and pure punctuation
* - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids
* - text inside <code>, <pre>, <script> and <style>
* - a component it cannot find a function body for
*
* Keys are `<namespace>.<slug>`, the namespace from the file name, the slug
* from the words themselves — so a key reads as what it says, and moving a
* heading between files does not rename it.
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, basename, extname } from 'node:path';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default || _traverse;
const generate = _generate.default || _generate;
const DRY = process.argv.includes('--dry');
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const TEXT_ATTRS = new Set([
'title', 'label', 'description', 'placeholder', 'subtitle', 'meta',
'aria-label', 'alt', 'emptyLabel', 'heading', 'hint', 'caption', 'tooltip',
'confirmLabel', 'cancelLabel', 'actionLabel', 'emptyTitle', 'emptyDescription',
]);
const SKIP_ELEMENTS = new Set(['code', 'pre', 'script', 'style']);
/* A string worth translating has letters, more than one of them, and is not a
token that only looks like prose (a route, a url, a css class, an enum). */
const translatable = (s) => {
const v = s.trim();
if (v.length < 2) return false;
if (!/[A-Za-z]{2}/.test(v)) return false;
if (/^[a-z0-9_-]+$/.test(v)) return false; // enum / slug / class
if (/^(https?:|\/|#|data:|mailto:)/.test(v)) return false;
if (/^[A-Z0-9_]+$/.test(v)) return false; // CONSTANT_CASE
return true;
};
const slug = (s) =>
s.trim().toLowerCase()
.replace(/[’']/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.split(' ')
.slice(0, 6)
.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1)))
.join('') || 'text';
const namespaceFor = (file) => {
const base = basename(file, extname(file));
if (base === 'nodes' || base === 'index') {
const parent = basename(join(file, '..'));
return parent.replace(/[^A-Za-z0-9]+/g, '');
}
return base.charAt(0).toLowerCase() + base.slice(1);
};
/** The nearest enclosing function that is a component (capitalised name). */
function enclosingComponent(path) {
let p = path;
while (p) {
if (p.isFunctionDeclaration() || p.isFunctionExpression() || p.isArrowFunctionExpression()) {
const id = p.node.id?.name
|| (p.parentPath?.isVariableDeclarator() && p.parentPath.node.id?.name);
/* Only a component with a BLOCK body can take the hook. An arrow whose
body is the JSX itself (`const X = () => <p>Text</p>`) would need
rewriting into a block first, and a codemod that reshapes a component
to translate one word is doing more than it was asked. Those are left
alone and reported, so they can be done by hand. */
if (id && /^[A-Z]/.test(id)) {
return t.isBlockStatement(p.node.body) ? p : null;
}
/* An inline render callback — keep walking out to the component that
owns it, so one hook serves both. */
}
p = p.parentPath;
}
return null;
}
/** True when the node sits in a module-level object/array literal. */
function atModuleScope(path) {
let p = path;
while (p) {
if (p.isFunction()) return false;
if (p.isProgram()) return true;
p = p.parentPath;
}
return true;
}
function processFile(file, catalogue) {
const src = readFileSync(file, 'utf8');
if (!/<[A-Za-z]/.test(src)) return null;
let ast;
try {
ast = parse(src, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'classProperties', 'decorators-legacy'],
});
} catch {
return { file, skipped: 'could not parse' };
}
/* A file that already binds `t` to something of its own — a theme token, a
translate helper, a loop variable — cannot also take the hook's `t`
without shadowing it. Skipped whole rather than half converted: the hook
would compile and then silently resolve to the wrong thing. */
if (/\b(?:const|let|var)\s+t\b\s*=/.test(src) || /\(\s*t\s*[,)]/.test(src)) {
return { file, skipped: 'already binds `t`' };
}
const ns = namespaceFor(file);
const needHook = new Set();
const found = [];
/* Character ranges to splice into the ORIGINAL text. Babel's generator would
reprint the whole file — restyling commas, quotes and JSX that nobody
asked it to touch — and a 52-file diff of incidental reformatting is one
nobody can review. */
const edits = [];
const keyFor = (text) => {
const key = `${ns}.${slug(text)}`;
const existing = catalogue.get(key);
if (existing && existing !== text) {
/* Same words, different file, different sentence: disambiguate rather
than let one overwrite the other. */
let i = 2;
while (catalogue.has(`${key}${i}`) && catalogue.get(`${key}${i}`) !== text) i += 1;
return `${key}${i}`;
}
return key;
};
traverse(ast, {
JSXText(path) {
const raw = path.node.value;
const text = raw.trim();
if (!translatable(text)) return;
const parent = path.parent;
if (t.isJSXElement(parent)) {
const name = parent.openingElement.name;
if (t.isJSXIdentifier(name) && SKIP_ELEMENTS.has(name.name)) return;
}
if (atModuleScope(path)) return;
const comp = enclosingComponent(path);
if (!comp) return;
const key = keyFor(text);
catalogue.set(key, text);
produced.add(key);
found.push({ key, text });
needHook.add(comp);
/* Only the trimmed span is replaced, so the surrounding newlines and
indentation survive exactly as written. */
const lead = raw.length - raw.trimStart().length;
edits.push({
start: path.node.start + lead,
end: path.node.start + lead + text.length,
text: `{t('${key}')}`,
});
},
JSXAttribute(path) {
const name = path.node.name;
const attr = t.isJSXIdentifier(name) ? name.name
: t.isJSXNamespacedName(name) ? `${name.namespace.name}:${name.name.name}` : null;
if (!attr || !TEXT_ATTRS.has(attr)) return;
const value = path.node.value;
if (!t.isStringLiteral(value)) return;
const text = value.value.trim();
if (!translatable(text)) return;
if (atModuleScope(path)) return;
const comp = enclosingComponent(path);
if (!comp) return;
const key = keyFor(text);
catalogue.set(key, text);
produced.add(key);
found.push({ key, text });
needHook.add(comp);
edits.push({ start: value.start, end: value.end, text: `{t('${key}')}` });
},
});
if (!found.length) return null;
/* One hook per component that now calls t(), on its own line just inside the
body. A component that already destructures t is left alone. */
for (const comp of needHook) {
const body = comp.node.body;
if (!t.isBlockStatement(body)) continue;
const already = body.body.some(
(st) => t.isVariableDeclaration(st)
&& st.declarations.some((d) => t.isObjectPattern(d.id)
&& d.id.properties.some((pr) => t.isObjectProperty(pr) && pr.key?.name === 't'))
);
if (already) continue;
/* Indentation copied from the first statement, so the inserted line sits
where a person would have typed it. */
const first = body.body[0];
const lineStart = first ? src.lastIndexOf('\n', first.start) + 1 : -1;
const indent = first ? src.slice(lineStart, first.start).match(/^[ \t]*/)[0] : ' ';
edits.push({
start: body.start + 1,
end: body.start + 1,
text: `\n${indent}const { t } = useTranslation();`,
});
}
/* Applied last-first so earlier offsets stay valid. */
let out = src;
edits.sort((a, b) => b.start - a.start);
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
if (!/from ['"]react-i18next['"]/.test(out)) {
const lines = out.split('\n');
let last = -1;
for (let i = 0; i < Math.min(lines.length, 60); i += 1) {
if (/^import .*;\s*$/.test(lines[i])) last = i;
}
lines.splice(last + 1, 0, "import { useTranslation } from 'react-i18next';");
out = lines.join('\n');
}
if (!DRY) writeFileSync(file, out);
return { file, found };
}
function walk(target, acc = []) {
const st = statSync(target);
if (st.isFile()) {
if (/\.(tsx|jsx)$/.test(target)) acc.push(target);
return acc;
}
for (const entry of readdirSync(target)) {
if (entry === 'node_modules' || entry.startsWith('.')) continue;
walk(join(target, entry), acc);
}
return acc;
}
/* Seeded from the catalogue already on disk, so a key taken by a DIFFERENT
string in an earlier run is seen as taken. Without this each run starts
blank, two runs mint the same key for two different sentences, and the
merge silently keeps one of them — which is how "Search positions" became
"Search positions...". */
const produced = new Set();
const catalogue = new Map();
try {
const existing = JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'));
for (const [ns, items] of Object.entries(existing)) {
if (items && typeof items === 'object') {
for (const [leaf, val] of Object.entries(items)) catalogue.set(`${ns}.${leaf}`, val);
}
}
} catch { /* first run, nothing to seed from */ }
const files = TARGETS.flatMap((tg) => walk(tg));
let changed = 0;
let strings = 0;
for (const file of files) {
const res = processFile(file, catalogue);
if (!res) continue;
if (res.skipped) { console.log(` skip ${file} — ${res.skipped}`); continue; }
changed += 1;
strings += res.found.length;
console.log(` ${DRY ? 'would do' : 'done'} ${file.replace('src/', '')} — ${res.found.length}`);
}
console.log(`\n ${changed} files, ${strings} strings${DRY ? ' (dry run, nothing written)' : ''}`);
/* ONLY the keys this run created. The catalogue is seeded from disk so
collisions are visible, and writing it back out whole would re-emit every
existing entry — including the nested ones this format does not model. */
const additions = {};
for (const [key, text] of catalogue) {
if (!produced.has(key)) continue;
const [ns, leaf] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)];
(additions[ns] ||= {})[leaf] = text;
}
writeFileSync('scripts/__i18n-additions.json', `${JSON.stringify(additions, null, 2)}\n`);
console.log(' catalogue written to scripts/__i18n-additions.json');

View File

@@ -1513,6 +1513,14 @@ record('the Owliver skill offers its chip, and draws no card',
*/
console.log('\n── Owliver suggestions from the API ──');
/* i18next, before ANY component renders. Every t() in the tree returns its own
key until this has run, so a block that forgets it fails its parity check for
a reason that has nothing to do with what it is testing. The app gets this
from App.tsx; a harness that loads page modules directly has to ask. Loaded
once here rather than per block, which is how two blocks came to be missing
it while a third had it. */
await server.ssrLoadModule('/src/lib/i18n/index.ts');
const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js');
/* ── The request ─────────────────────────────────────────────────────────── */
@@ -3869,7 +3877,11 @@ record('both agent menus offer removal, and neither offers it unconditionally',
`${removeItems.length} remove item(s), each behind (overridden || !shipped)`);
record('a custom agent is offered Remove, and a built-in one is only offered a revert',
removeItems.every((block) => / Remove</.test(block) && /Revert to shipped/.test(block)
/* Matched by translation KEY rather than by the English words: the labels
now come from the catalogue, and a check that greps for "Remove" would be
asserting on which language the file happens to be written in. */
removeItems.every((block) => /workspaceAgents\.remove/.test(block)
&& /workspaceAgents\.revertToShipped/.test(block)
&& /shipped\s*\?/.test(block)),
'the label is chosen by `shipped`, not by name');
@@ -3923,7 +3935,7 @@ record('...while Archived stays reachable as the place removal is undone',
the confirmation names "Archived" while no such tab is on screen. */
record('...and Archived is offered even when it is empty',
!/\{archivedCount > 0 && \(/.test(agentsListSource)
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
&& /workspaceAgents\.archived'\)\} <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
'the tab is unconditional and shows its count');
record('...and reverting a built-in agent is not worded as a removal',
@@ -4880,7 +4892,7 @@ record('every field the editor had is still editable',
record('the header still states version, status and customization',
/v\{agent\.version\}/.test(detailSource)
&& /\{agent\.status\}/.test(detailSource)
&& /isOverridden\(id\) && <Badge variant="info">Customized/.test(detailSource)
&& /isOverridden\(id\) && <Badge variant="info">\{t\('agentDetail\.customized'\)\}/.test(detailSource)
&& /Publish update/.test(detailSource),
'v · status · Customized · Publish update');
@@ -9082,7 +9094,14 @@ console.log('\n── Candidates vs Talent Pool ──');
}
html = html.slice(0, at) + html.slice(i);
}
return { pass: html === positionsBefore, detail: html === positionsBefore ? '' : `len ${positionsBefore.length}->${html.length}` };
/* Compared with runs of whitespace collapsed. Text that used to be
written across several lines of JSX now comes from the catalogue as
one line, so the markup differs by newlines and indentation that HTML
collapses anyway. Everything this check exists to catch — a changed
element, class, attribute or word — still fails it. */
const ws = (h) => h.replace(/\s+/g, ' ').trim();
const same = ws(html) === ws(positionsBefore);
return { pass: same, detail: same ? '' : `len ${positionsBefore.length}->${html.length}` };
})().pass);
}
}
@@ -9323,6 +9342,106 @@ console.log('\n── Candidates vs Talent Pool ──');
return { pass: ids.length > 0, detail: ids.join(', ') };
})().pass);
}
/* ── The page actually translates ───────────────────────────────────────
*
* The parity checks above all run in English, so every one of them would
* still pass if `t()` were wired up and the Spanish catalogue were empty.
* This renders the same page in Spanish and insists the words CHANGED.
*
* Asserting on specific strings rather than merely "different": a page
* where t() returns raw keys also differs from English, and that is the
* failure this is most likely to catch.
*/
{
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
const Page = (await server.ssrLoadModule('/src/pages/admin/ControlCenter.jsx')).default;
const render = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
return stripControls(renderToStaticMarkup(
React.createElement(R, { initialEntries: ['/admin'] },
React.createElement(QueryClientProvider, { client },
React.createElement(UiEditingProvider, { page: 'control-center' },
React.createElement(Page))))
));
};
const en = words(render());
await i18n.changeLanguage('es');
const es = words(render());
await i18n.changeLanguage('en');
record('Control Center renders in Spanish', es !== en);
record('Control Center shows Spanish words, not keys', (() => {
const want = ['Centro de control', 'Vacantes abiertas', 'Candidatos'];
const missing = want.filter((w) => !es.includes(w));
const keys = /controlCenter\.title|metrics\.|sections\./.test(es);
return {
pass: missing.length === 0 && !keys,
detail: keys ? 'raw keys rendered' : (missing.length ? `missing ${missing.join(', ')}` : 'translated'),
};
})().pass);
record('switching back restores English', words(render()) === en);
/* THE CHECK THAT MATTERS, and the one whose absence let a half-translated
page be reported as finished. "es !== en" passes when a single word
changed, which is exactly the state a reader sees as broken: Spanish
headings sitting on top of English captions, legends and stage names.
So this names the English the page used to show and insists none of it
survives the switch. A key added to the catalogue but never wired into
the component fails here, where every other check stays green. */
record('no English survives on the Spanish Control Center', (() => {
const english = [
'Applications, screening, interviews and hires',
'entered · conversion and drop-off by stage',
'Entry Stage', 'Primary Pipeline Bottleneck',
'pass through and', 'Open positions', 'In review', 'AI screened',
'Avg KROW score', 'Hiring activity', 'Pipeline intelligence',
'Position performance', 'Action center', 'Active positions',
'Top talent', 'Recent activity', 'Shortlisted', 'Interview',
];
const left = english.filter((e) => es.includes(e));
return { pass: left.length === 0, detail: left.length ? `still English: ${left.join(' | ')}` : 'fully translated' };
})().pass);
/* The panel writes its own greeting, description and placeholders in the
browser — `dynamic.ts` — so a translated DASHBOARD with an English
Owliver is the exact half-done state this is here to catch. */
const dyn = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.ts');
const facts = {
today: new Date('2026-10-07T09:00:00'),
total: 24, postings: new Array(8),
unscoredProfiles: [], activity24h: [],
};
const esIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('es'));
const enIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('en'));
record('the assistant greeting translates', (() => {
const ok = esIntro.title.startsWith('Buenos días') && enIntro.title.startsWith('Good morning');
return { pass: ok, detail: `${enIntro.title} / ${esIntro.title}` };
})().pass);
record('the assistant greeting agrees in gender and number', (() => {
/* "Buenos días" and "Buenas tardes" — the adjective agrees with the
noun, so a greeting assembled from "Good " plus a translated word is
wrong in one of them. One key per time of day is why this passes. */
const afternoon = { ...facts, today: new Date('2026-10-07T14:00:00') };
const pm = dyn.buildIntro('admin.controlCenter', afternoon, '', i18n.getFixedT('es'));
return { pass: pm.title === 'Buenas tardes', detail: pm.title };
})().pass);
record('the assistant description counts in Spanish', (() => {
const ok = esIntro.description.includes('24 solicitudes')
&& esIntro.description.includes('8 vacantes');
return { pass: ok, detail: esIntro.description };
})().pass);
record('a singular count does not take the plural form', (() => {
const one = { ...facts, total: 1, postings: new Array(1) };
const d = dyn.buildIntro('admin.controlCenter', one, '', i18n.getFixedT('es')).description;
return { pass: d.includes('1 solicitud ') || d.includes('1 solicitud'), detail: d };
})().pass);
}
}
}