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
144 changes: 144 additions & 0 deletions src/ui/admin/readModels/searchLanguagesReadModel.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -42,13 +47,21 @@ test("fetches the first page", async () => {
localName: lang1.local_name,
otProgress: 0,
ntProgress: 0,
aiGlosses: {
status: "unavailable",
lastSyncedAt: null,
},
},
{
code: lang2.code,
englishName: lang2.english_name,
localName: lang2.local_name,
otProgress: 0,
ntProgress: 0,
aiGlosses: {
status: "unavailable",
lastSyncedAt: null,
},
},
],
});
Expand Down Expand Up @@ -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,
},
},
],
});
Expand Down
59 changes: 58 additions & 1 deletion src/ui/admin/readModels/searchLanguagesReadModel.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,6 +17,10 @@ export interface SearchLanguagesReadModel {
localName: string;
otProgress: number;
ntProgress: number;
aiGlosses: {
status: "unavailable" | "available" | "in-progress" | "imported";
lastSyncedAt: Date | null;
};
}>;
}

Expand All @@ -27,18 +35,67 @@ 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)
.limit(options.limit)
.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,
},
})),
};
}
60 changes: 60 additions & 0 deletions src/ui/admin/routes/_main.languages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ function AdminLanguagesRoute() {
<ListHeaderCell className="min-w-[120px]">
NT Progress
</ListHeaderCell>
<ListHeaderCell className="min-w-[120px]">
AI Glosses
</ListHeaderCell>
<ListHeaderCell />
</ListHeader>
<ListBody>
Expand All @@ -79,6 +82,9 @@ function AdminLanguagesRoute() {
</ListCell>
<ListCell>{(100 * language.otProgress).toFixed(2)}%</ListCell>
<ListCell>{(100 * language.ntProgress).toFixed(2)}%</ListCell>
<ListCell className="pe-4">
<AIGlossesStatus aiGlosses={language.aiGlosses} />
</ListCell>
<ListCell>
<Button
variant="tertiary"
Expand All @@ -98,3 +104,57 @@ function AdminLanguagesRoute() {
</div>
);
}

function AIGlossesStatus({
aiGlosses,
}: {
aiGlosses: {
status: "unavailable" | "available" | "in-progress" | "imported";
lastSyncedAt: Date | null;
};
}) {
const status = formatAIGlossesStatus(aiGlosses);

return (
<span className="inline-flex items-center gap-1">
<Icon className={status.className} icon={status.icon} fixedWidth />
<span>{status.label}</span>
</span>
);
}

function formatAIGlossesStatus(aiGlosses: {
status: "unavailable" | "available" | "in-progress" | "imported";
lastSyncedAt: Date | null;
}) {
switch (aiGlosses.status) {
case "unavailable":
return {
icon: "xmark" as const,
label: "None available",
};
case "in-progress":
return {
icon: "arrows-rotate" as const,
label: "Import in progress",
};
case "imported":
return {
icon: "check" as const,
label:
aiGlosses.lastSyncedAt ?
`Imported on ${aiGlosses.lastSyncedAt.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}`
: "Available to import",
};
case "available":
return {
icon: "file-import" as const,
label: "Available to import",
className: "text-blue-800 dark:text-green-400",
};
}
}
Loading