From ccc834227ef26e398065c22e95b076e03fcaf848 Mon Sep 17 00:00:00 2001 From: Jannie Theunissen Date: Fri, 31 Jul 2026 15:37:51 +0200 Subject: [PATCH 1/3] increment: update locale endpoint --- src/backend/services/locale_service.ts | 57 +++++- .../stubs/tests/unit/locale_service.stub | 165 ++++++++++++++---- src/types.ts | 4 +- 3 files changed, 186 insertions(+), 40 deletions(-) diff --git a/src/backend/services/locale_service.ts b/src/backend/services/locale_service.ts index 83b5c91b..db9e4376 100644 --- a/src/backend/services/locale_service.ts +++ b/src/backend/services/locale_service.ts @@ -1,12 +1,21 @@ import Story from '../models/story.js'; +import Ui from '../models/ui.js'; import { parseLanguageSpecification } from '../../shared/language_helpers.js'; -import type { CmsConfig, LocaleItem, LocaleIndexResponse } from '../../types.js'; +import { FlagState } from '../../types.js'; +import type { CmsConfig, LocaleIndexResponse } from '../../types.js'; + +const APP_UI_TRANSLATION_THRESHOLD = 0.8; export class LocaleService { public constructor(protected readonly config: CmsConfig) {} + public get sourceLocale(): string { + return this.config.languages[0].locale; + } + public async localeIndex(): Promise { const locales = this.config.languages.map((language) => language.locale); + const languages = this.config.languages.map(parseLanguageSpecification); const stories = await Story.query() .select('id', 'slug', 'order') @@ -30,15 +39,45 @@ export class LocaleService { .map((locale) => ({ locale, stories: byLocale.get(locale) ?? [] })) .filter((item) => item.stories.length > 0); - const languagesByLocale = new Map( - this.config.languages.map((language) => [language.locale, language]), - ); + const app = await this.appLocales(locales); + + return { + languages, + content, + app, + media: [this.sourceLocale], + }; + } + + private async appLocales(locales: string[]): Promise { + const sourceLocale = this.sourceLocale; + const rows = await Ui.query().whereIn('locale', locales); + const totalUiCount = rows.filter((row) => row.locale === sourceLocale).length; + + const app = [sourceLocale]; - const app: LocaleItem[] = content.map(({ locale }) => { - const spec = languagesByLocale.get(locale)!; - return parseLanguageSpecification(spec); - }); + if (totalUiCount === 0) { + return app; + } + + for (const locale of locales) { + if (locale === sourceLocale) { + continue; + } + + const translatedCount = rows.filter( + (row) => + row.locale === locale && + row.microCopy && + row.microCopy.trim() !== '' && + row.flag !== FlagState.PREFILLED, + ).length; + + if (translatedCount / totalUiCount > APP_UI_TRANSLATION_THRESHOLD) { + app.push(locale); + } + } - return { content, app }; + return app; } } diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index 7da9b29d..2ab56a1f 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -8,6 +8,7 @@ import { StoryFactory, StoryLocalisation, StoryLocalisationFactory, + Ui, } from '@story-cms/kit'; import { testCmsConfig, createCustomCmsConfig } from '#tests/helpers/cms_mock'; @@ -35,6 +36,56 @@ async function publishInLocale(storyId: number, locale: string) { }).create(); } +async function seedUiStrings( + sourceKeys: string[], + translations: Record>, +) { + const rows = sourceKeys.flatMap((key) => { + const sourceRows = [ + { + locale: sourceLocale, + key, + microCopy: `Source ${key}`, + flag: null, + updatedBy: 1, + }, + ]; + + const localeRows = Object.entries(translations).flatMap(([locale, items]) => { + const item = items[key]; + if (!item) { + return []; + } + + if (typeof item === 'string') { + return [ + { + locale, + key, + microCopy: item, + flag: null, + updatedBy: 1, + }, + ]; + } + + return [ + { + locale, + key, + microCopy: item.text, + flag: item.flag ?? null, + updatedBy: 1, + }, + ]; + }); + + return [...sourceRows, ...localeRows]; + }); + + await Ui.createMany(rows); +} + test.group('LocaleService', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()); @@ -60,10 +111,7 @@ test.group('LocaleService', (group) => { assert.isDefined(translationEntry); assert.deepEqual(sourceEntry!.stories, ['classic', 'youth']); assert.deepEqual(translationEntry!.stories, ['classic']); - assert.deepEqual( - app.map((item) => item.locale), - content.map((item) => item.locale), - ); + assert.deepEqual(app, [sourceLocale]); }); test('respects publish state per locale', async ({ assert }) => { @@ -89,7 +137,7 @@ test.group('LocaleService', (group) => { assert.isDefined(sourceEntry); assert.deepEqual(sourceEntry!.stories, ['express']); assert.isUndefined(translationEntry); - assert.deepEqual(app.map((item) => item.locale), [sourceLocale]); + assert.deepEqual(app, [sourceLocale]); }); test('omits locales with no published stories', async ({ assert }) => { @@ -105,8 +153,7 @@ test.group('LocaleService', (group) => { assert.isTrue(content.every((item) => item.stories.length > 0)); assert.isUndefined(content.find((item) => item.locale === translationLocale)); assert.isUndefined(content.find((item) => item.locale === thirdLocale)); - assert.isUndefined(app.find((item) => item.locale === translationLocale)); - assert.isUndefined(app.find((item) => item.locale === thirdLocale)); + assert.deepEqual(app, [sourceLocale]); }); test('preserves story order within each locale', async ({ assert }) => { @@ -126,7 +173,7 @@ test.group('LocaleService', (group) => { const sourceEntry = content.find((item) => item.locale === sourceLocale); assert.isDefined(sourceEntry); assert.deepEqual(sourceEntry!.stories, ['first', 'second']); - assert.deepEqual(app.map((item) => item.locale), [sourceLocale]); + assert.deepEqual(app, [sourceLocale]); }); test('uses configured locale order in response', async ({ assert }) => { @@ -138,19 +185,19 @@ test.group('LocaleService', (group) => { await publishInLocale(story.id, translationLocale); const service = new LocaleService(testCmsConfig); - const { content, app } = await service.localeIndex(); + const { content, languages } = await service.localeIndex(); assert.deepEqual( content.map((item) => item.locale), [sourceLocale, translationLocale], ); assert.deepEqual( - app.map((item) => item.locale), - [sourceLocale, translationLocale], + languages.map((item) => item.locale), + [sourceLocale, translationLocale, thirdLocale], ); }); - test('returns parsed name and nativeName in app metadata', async ({ assert }) => { + test('returns all configured languages in languages metadata', async ({ assert }) => { const config = createCustomCmsConfig({ languages: [ { @@ -166,23 +213,16 @@ test.group('LocaleService', (group) => { ], }); - const story = await StoryFactory.with('localisations') - .merge({ slug: 'localized', order: 1 }) - .create(); - - await publishInLocale(story.id, 'en'); - await publishInLocale(story.id, 'es'); - const service = new LocaleService(config); - const { app } = await service.localeIndex(); + const { languages } = await service.localeIndex(); - assert.deepEqual(app, [ + assert.deepEqual(languages, [ { locale: 'en', name: 'English', nativeName: 'English', languageDirection: 'ltr' }, { locale: 'es', name: 'Spanish', nativeName: 'EspaƱol', languageDirection: 'ltr' }, ]); }); - test('returns languageDirection in app metadata', async ({ assert }) => { + test('returns languageDirection in languages metadata', async ({ assert }) => { const config = createCustomCmsConfig({ languages: [ { @@ -193,16 +233,10 @@ test.group('LocaleService', (group) => { ], }); - const story = await StoryFactory.with('localisations') - .merge({ slug: 'rtl-story', order: 1 }) - .create(); - - await publishInLocale(story.id, 'ar'); - const service = new LocaleService(config); - const { app } = await service.localeIndex(); + const { languages } = await service.localeIndex(); - assert.deepEqual(app, [ + assert.deepEqual(languages, [ { locale: 'ar', name: 'Arabic', @@ -211,4 +245,75 @@ test.group('LocaleService', (group) => { }, ]); }); + + test('always includes source locale in app', async ({ assert }) => { + const service = new LocaleService(testCmsConfig); + const { app } = await service.localeIndex(); + + assert.deepEqual(app, [sourceLocale]); + }); + + test('includes locales with more than 80% non-AI UI translation in app', async ({ + assert, + }) => { + await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], { + [translationLocale]: { + k1: 'Uno', + k2: 'Dos', + k3: 'Tres', + k4: 'Cuatro', + k5: 'Cinco', + }, + [thirdLocale]: { + k1: 'One', + k2: 'Two', + }, + }); + + const service = new LocaleService(testCmsConfig); + const { app } = await service.localeIndex(); + + assert.deepEqual(app, [sourceLocale, translationLocale]); + }); + + test('excludes AI-prefilled translations from app progress', async ({ assert }) => { + await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], { + [translationLocale]: { + k1: 'Uno', + k2: 'Dos', + k3: 'Tres', + k4: 'Cuatro', + k5: { text: 'Cinco', flag: 'prefilled' }, + }, + }); + + const service = new LocaleService(testCmsConfig); + const { app } = await service.localeIndex(); + + assert.deepEqual(app, [sourceLocale]); + }); + + test('counts recheck translations toward app progress', async ({ assert }) => { + await seedUiStrings(['k1', 'k2', 'k3', 'k4', 'k5'], { + [translationLocale]: { + k1: 'Uno', + k2: 'Dos', + k3: 'Tres', + k4: 'Cuatro', + k5: { text: 'Cinco', flag: 'recheck' }, + }, + }); + + const service = new LocaleService(testCmsConfig); + const { app } = await service.localeIndex(); + + assert.deepEqual(app, [sourceLocale, translationLocale]); + }); + + test('returns source locale in media', async ({ assert }) => { + const service = new LocaleService(testCmsConfig); + const { media } = await service.localeIndex(); + + assert.deepEqual(media, [sourceLocale]); + }); }); diff --git a/src/types.ts b/src/types.ts index 8d8ef2de..23006fd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -422,8 +422,10 @@ export interface LocaleItem extends Pick< } export interface LocaleIndexResponse { + languages: LocaleItem[]; content: LocaleContentItem[]; - app: LocaleItem[]; + app: string[]; + media: string[]; } export interface DraftEditProps { From bf584311221706ce8b20e7337129ca91aa41ea73 Mon Sep 17 00:00:00 2001 From: Jannie Theunissen Date: Fri, 31 Jul 2026 15:57:57 +0200 Subject: [PATCH 2/3] fix: locale service test stub --- src/backend/stubs/tests/unit/locale_service.stub | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index 2ab56a1f..f80ab6ad 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -45,7 +45,7 @@ async function seedUiStrings( { locale: sourceLocale, key, - microCopy: `Source ${key}`, + microCopy: {{ '`Source ${key}`' }}, flag: null, updatedBy: 1, }, From ac0cbb943890469f952f1f10bb3531358342b076 Mon Sep 17 00:00:00 2001 From: Jannie Theunissen Date: Fri, 31 Jul 2026 16:32:09 +0200 Subject: [PATCH 3/3] fix: locale service test --- src/backend/stubs/tests/unit/locale_service.stub | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/backend/stubs/tests/unit/locale_service.stub b/src/backend/stubs/tests/unit/locale_service.stub index f80ab6ad..8aff4877 100644 --- a/src/backend/stubs/tests/unit/locale_service.stub +++ b/src/backend/stubs/tests/unit/locale_service.stub @@ -9,6 +9,7 @@ import { StoryLocalisation, StoryLocalisationFactory, Ui, + User, } from '@story-cms/kit'; import { testCmsConfig, createCustomCmsConfig } from '#tests/helpers/cms_mock'; @@ -40,6 +41,14 @@ async function seedUiStrings( sourceKeys: string[], translations: Record>, ) { + await User.create({ + id: 1, + name: 'Test User', + email: 'test@test.com', + password: 'password', + language: '*', + }); + const rows = sourceKeys.flatMap((key) => { const sourceRows = [ { @@ -87,7 +96,7 @@ async function seedUiStrings( } test.group('LocaleService', (group) => { - group.each.setup(() => testUtils.db().withGlobalTransaction()); + group.each.setup(() => testUtils.db().wrapInGlobalTransaction()); test('returns slugs grouped by locale', async ({ assert }) => { const classic = await StoryFactory.with('localisations')