diff --git a/src/ui/admin/readModels/searchLanguagesReadModel.test.ts b/src/ui/admin/readModels/searchLanguagesReadModel.test.ts
index 8f8ea327..4ef37a40 100644
--- a/src/ui/admin/readModels/searchLanguagesReadModel.test.ts
+++ b/src/ui/admin/readModels/searchLanguagesReadModel.test.ts
@@ -1,5 +1,10 @@
import { initializeDatabase } from "@/tests/vitest/dbUtils";
+import { getDb } from "@/db";
+import { TRANSLATION_JOB_TYPES } from "@/modules/translation/jobs/jobType";
+import { JobStatus } from "@/shared/jobs/model";
+import { ulid } from "@/shared/ulid";
import { expect, test } from "vitest";
+
import { searchLanguagesReadModel } from "@/ui/admin/readModels/searchLanguagesReadModel";
import { languageFactory } from "@/modules/languages/test-utils/languageFactory";
@@ -42,6 +47,10 @@ test("fetches the first page", async () => {
localName: lang1.local_name,
otProgress: 0,
ntProgress: 0,
+ aiGlosses: {
+ status: "unavailable",
+ lastSyncedAt: null,
+ },
},
{
code: lang2.code,
@@ -49,6 +58,10 @@ test("fetches the first page", async () => {
localName: lang2.local_name,
otProgress: 0,
ntProgress: 0,
+ aiGlosses: {
+ status: "unavailable",
+ lastSyncedAt: null,
+ },
},
],
});
@@ -82,6 +95,137 @@ test("fetches the second page", async () => {
localName: lang3.local_name,
otProgress: 0,
ntProgress: 0,
+ aiGlosses: {
+ status: "unavailable",
+ lastSyncedAt: null,
+ },
+ },
+ ],
+ });
+});
+
+test("includes AI glosses availability and sync metadata", async () => {
+ const { language: deu } = await languageFactory.build({
+ code: "deu",
+ englishName: "German",
+ localName: "Deutsch",
+ });
+ const { language: eng } = await languageFactory.build({
+ code: "eng",
+ englishName: "English",
+ localName: "English",
+ });
+ const { language: fra } = await languageFactory.build({
+ code: "fra",
+ englishName: "French",
+ localName: "Français",
+ });
+ const { language: spa } = await languageFactory.build({
+ code: "spa",
+ englishName: "Spanish",
+ localName: "Español",
+ });
+
+ await getDb()
+ .insertInto("ai_gloss_language")
+ .values([
+ { code: "eng", name: "English" },
+ { code: "fra", name: "French" },
+ { code: "spa", name: "Spanish" },
+ ])
+ .execute();
+
+ const engImportedAt = new Date("2026-04-10T00:00:00.000Z");
+ const fraImportedAt = new Date("2026-04-05T00:00:00.000Z");
+ const fraPendingAt = new Date("2026-04-12T00:00:00.000Z");
+ const spaFailedAt = new Date("2026-04-15T00:00:00.000Z");
+
+ await getDb()
+ .insertInto("job")
+ .values([
+ {
+ id: ulid(),
+ type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES,
+ status: JobStatus.Complete,
+ payload: { languageCode: "eng" },
+ created_at: engImportedAt,
+ updated_at: engImportedAt,
+ },
+ {
+ id: ulid(),
+ type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES,
+ status: JobStatus.Complete,
+ payload: { languageCode: "fra" },
+ created_at: fraImportedAt,
+ updated_at: fraImportedAt,
+ },
+ {
+ id: ulid(),
+ type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES,
+ status: JobStatus.Pending,
+ payload: { languageCode: "fra" },
+ created_at: fraPendingAt,
+ updated_at: fraPendingAt,
+ },
+ {
+ id: ulid(),
+ type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES,
+ status: JobStatus.Failed,
+ payload: { languageCode: "spa" },
+ created_at: spaFailedAt,
+ updated_at: spaFailedAt,
+ },
+ ])
+ .execute();
+
+ const result = await searchLanguagesReadModel({ page: 0, limit: 10 });
+
+ expect(result).toEqual({
+ total: 4,
+ page: [
+ {
+ code: eng.code,
+ englishName: eng.english_name,
+ localName: eng.local_name,
+ otProgress: 0,
+ ntProgress: 0,
+ aiGlosses: {
+ status: "imported",
+ lastSyncedAt: engImportedAt,
+ },
+ },
+ {
+ code: fra.code,
+ englishName: fra.english_name,
+ localName: fra.local_name,
+ otProgress: 0,
+ ntProgress: 0,
+ aiGlosses: {
+ status: "in-progress",
+ lastSyncedAt: fraPendingAt,
+ },
+ },
+ {
+ code: deu.code,
+ englishName: deu.english_name,
+ localName: deu.local_name,
+ otProgress: 0,
+ ntProgress: 0,
+ aiGlosses: {
+ status: "unavailable",
+ lastSyncedAt: null,
+ },
+ },
+ {
+ code: spa.code,
+ englishName: spa.english_name,
+ localName: spa.local_name,
+ otProgress: 0,
+ ntProgress: 0,
+ aiGlosses: {
+ status: "available",
+ lastSyncedAt: null,
+ },
},
],
});
diff --git a/src/ui/admin/readModels/searchLanguagesReadModel.ts b/src/ui/admin/readModels/searchLanguagesReadModel.ts
index 693768ba..9fc8a38b 100644
--- a/src/ui/admin/readModels/searchLanguagesReadModel.ts
+++ b/src/ui/admin/readModels/searchLanguagesReadModel.ts
@@ -1,4 +1,8 @@
+import { sql } from "kysely";
import { getDb } from "@/db";
+import { TRANSLATION_JOB_TYPES } from "@/modules/translation/jobs/jobType";
+import { JobStatus } from "@/shared/jobs/model";
+import { jsonBuildObject } from "kysely/helpers/postgres";
export interface SearchLanguagesReadModelRequest {
page: number;
@@ -13,6 +17,10 @@ export interface SearchLanguagesReadModel {
localName: string;
otProgress: number;
ntProgress: number;
+ aiGlosses: {
+ status: "unavailable" | "available" | "in-progress" | "imported";
+ lastSyncedAt: Date | null;
+ };
}>;
}
@@ -27,12 +35,45 @@ export async function searchLanguagesReadModel(
getDb()
.selectFrom("language as l")
.leftJoin("language_progress as p", "p.code", "l.code")
+ .leftJoin("ai_gloss_language", "ai_gloss_language.code", "l.code")
+ .leftJoinLateral(
+ (db) =>
+ db
+ .selectFrom("job")
+ .where("type", "=", TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES)
+ .where("status", "<>", JobStatus.Failed)
+ .where((eb) =>
+ eb(
+ "payload",
+ "@>",
+ sql`jsonb_build_object('languageCode', ${eb.ref("l.code")})`,
+ ),
+ )
+ .orderBy("created_at", "desc")
+ .limit(1)
+ .select(["status", "updated_at"])
+ .as("ai_import_job"),
+ (jb) => jb.onTrue(),
+ )
.select((eb) => [
"l.code",
"l.english_name as englishName",
"l.local_name as localName",
eb.fn.coalesce("p.ot_progress", eb.lit(0)).as("otProgress"),
eb.fn.coalesce("p.nt_progress", eb.lit(0)).as("ntProgress"),
+ jsonBuildObject({
+ status: eb
+ .case()
+ .when("ai_import_job.status", "=", JobStatus.Complete)
+ .then(sql.lit("imported" as const))
+ .when("ai_import_job.status", "is not", null)
+ .then(sql.lit("in-progress" as const))
+ .when("ai_gloss_language.code", "is not", null)
+ .then(sql.lit("available" as const))
+ .else(sql.lit("unavailable" as const))
+ .end(),
+ lastSyncedAt: eb.ref("ai_import_job.updated_at"),
+ }).as("aiGlosses"),
])
.orderBy("l.english_name")
.offset(options.page * options.limit)
@@ -40,5 +81,21 @@ export async function searchLanguagesReadModel(
.execute(),
]);
- return { total: totalResult?.total ?? 0, page };
+ if (page.length === 0) {
+ return { total: totalResult?.total ?? 0, page: [] };
+ }
+
+ return {
+ total: totalResult?.total ?? 0,
+ page: page.map((language) => ({
+ ...language,
+ aiGlosses: {
+ ...language.aiGlosses,
+ lastSyncedAt:
+ language.aiGlosses.lastSyncedAt ?
+ new Date(language.aiGlosses.lastSyncedAt)
+ : null,
+ },
+ })),
+ };
}
diff --git a/src/ui/admin/routes/_main.languages/index.tsx b/src/ui/admin/routes/_main.languages/index.tsx
index dba59cf2..a40783a3 100644
--- a/src/ui/admin/routes/_main.languages/index.tsx
+++ b/src/ui/admin/routes/_main.languages/index.tsx
@@ -60,6 +60,9 @@ function AdminLanguagesRoute() {
NT Progress
+
+ AI Glosses
+
@@ -79,6 +82,9 @@ function AdminLanguagesRoute() {
{(100 * language.otProgress).toFixed(2)}%
{(100 * language.ntProgress).toFixed(2)}%
+
+
+