From 511084dac738b30c4f2fc91e9cf699e6ba5007b1 Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 21:13:31 -0500 Subject: [PATCH 1/2] feat: languages admin dashboard card --- .../PlatformLanguagesDashboardCard.tsx | 150 +++++++++++ .../getPlatformDashboardLanguagesReadModel.ts | 239 ++++++++++++++++++ src/ui/admin/routes/_main.dashboard.tsx | 20 +- .../getPlatformDashboardLanguages.ts | 25 ++ 4 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 src/ui/admin/components/PlatformLanguagesDashboardCard.tsx create mode 100644 src/ui/admin/readModels/getPlatformDashboardLanguagesReadModel.ts create mode 100644 src/ui/admin/serverFns/getPlatformDashboardLanguages.ts diff --git a/src/ui/admin/components/PlatformLanguagesDashboardCard.tsx b/src/ui/admin/components/PlatformLanguagesDashboardCard.tsx new file mode 100644 index 00000000..21c91bd3 --- /dev/null +++ b/src/ui/admin/components/PlatformLanguagesDashboardCard.tsx @@ -0,0 +1,150 @@ +import { useMemo } from "react"; +import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"; +import { + DashboardCard, + DashboardCardEmptyState, + DashboardCardHeader, +} from "./DashboardCard"; +import ProgressBar from "./ProgressBar"; +import Button from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import InfiniteFetchTrigger from "@/components/InfiniteFetchTrigger"; +import { getPlatformDashboardLanguages } from "@/ui/admin/serverFns/getPlatformDashboardLanguages"; +import { type ActivityChartRange } from "./ActivityChart"; +import ActivityChart from "./ActivityChart"; + +const PAGE_SIZE = 10; + +export function platformDashboardLanguagesInfiniteQueryOptions( + range: ActivityChartRange, +) { + return infiniteQueryOptions({ + queryKey: ["platformDashboardLanguages", range], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + getPlatformDashboardLanguages({ + data: { + range, + limit: PAGE_SIZE, + cursor: pageParam, + }, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); +} + +export default function PlatformLanguagesDashboardCard({ + className = "", + range, +}: { + className?: string; + range: ActivityChartRange; +}) { + const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery(platformDashboardLanguagesInfiniteQueryOptions(range)); + + const languages = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data], + ); + + const yMin = languages.reduce( + (min, language) => + language.activity.reduce( + (entryMin, entry) => Math.min(entryMin, entry.net), + min, + ), + 0, + ); + const yMax = languages.reduce( + (max, language) => + language.activity.reduce( + (entryMax, entry) => Math.max(entryMax, entry.net), + max, + ), + 0, + ); + + return ( + + +
+ {languages.length === 0 ? + No languages found. + :
+ {languages.map((language) => ( +
+
+ + + {language.code} + +
+ +
+
+ +
+
+
+ OT +
+ +
+ {(language.otProgress * 100).toFixed(0)}% +
+ +
+ NT +
+ +
+ {(language.ntProgress * 100).toFixed(0)}% +
+
+
+ + +
+ ))} + + { + void fetchNextPage(); + }} + > + Loading more languages... + +
+ } +
+
+ ); +} diff --git a/src/ui/admin/readModels/getPlatformDashboardLanguagesReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardLanguagesReadModel.ts new file mode 100644 index 00000000..fadb541c --- /dev/null +++ b/src/ui/admin/readModels/getPlatformDashboardLanguagesReadModel.ts @@ -0,0 +1,239 @@ +import { getDb } from "@/db"; +import { GlossStateRaw } from "@/modules/translation/types"; +import { sql } from "kysely"; + +export interface PlatformDashboardLanguageActivityEntryReadModel { + date: Date; + net: number; +} + +export interface PlatformDashboardLanguageReadModel { + id: string; + code: string; + englishName: string; + localName: string; + otProgress: number; + ntProgress: number; + activityTotal: number; + activity: PlatformDashboardLanguageActivityEntryReadModel[]; +} + +export interface PlatformDashboardLanguagesReadModel { + items: PlatformDashboardLanguageReadModel[]; + nextCursor: string | null; +} + +export async function getPlatformDashboardLanguagesReadModel({ + range, + limit, + cursor, +}: { + range: "30d" | "6m"; + limit: number; + cursor?: string; +}): Promise { + const { rangeDays, granularity } = + range === "30d" ? + { rangeDays: 30, granularity: "day" as const } + : { rangeDays: 182, granularity: "week" as const }; + + const parsedCursor = cursor ? decodeCursor(cursor) : null; + + const { languages, hasNextPage } = await queryLanguagePageRows({ + limit, + cursor: parsedCursor, + }); + + if (languages.length === 0) { + return { + items: [], + nextCursor: null, + }; + } + + const { activityMap, activityTotalMap } = await queryLanguageActivityRows({ + languageIds: languages.map((row) => row.id), + rangeDays, + granularity, + }); + + const lastRow = languages[languages.length - 1]; + const nextCursor = + hasNextPage && lastRow ? + encodeCursor({ + totalProgress: lastRow.totalProgress, + languageName: lastRow.englishName, + }) + : null; + + return { + items: languages.map((row) => ({ + id: row.id, + code: row.code, + englishName: row.englishName, + localName: row.localName, + otProgress: row.otProgress, + ntProgress: row.ntProgress, + activity: activityMap.get(row.id) ?? [], + activityTotal: activityTotalMap.get(row.id) ?? 0, + })), + nextCursor, + }; +} + +async function queryLanguagePageRows({ + limit, + cursor, +}: { + limit: number; + cursor: CursorData | null; +}) { + const totalProgressExpr = sql`coalesce(p.ot_progress, 0) + coalesce(p.nt_progress, 0)`; + + let query = getDb() + .selectFrom("language as l") + .leftJoin("language_progress as p", "p.code", "l.code") + .select([ + "l.id", + "l.code", + "l.english_name as englishName", + "l.local_name as localName", + (eb) => eb.fn.coalesce("p.ot_progress", eb.lit(0)).as("otProgress"), + (eb) => eb.fn.coalesce("p.nt_progress", eb.lit(0)).as("ntProgress"), + totalProgressExpr.as("totalProgress"), + ]) + .orderBy(totalProgressExpr, "desc") + .orderBy((eb) => eb.fn.coalesce("l.english_name", sql.lit(""))) + .orderBy("l.code") + .limit(limit + 1); + + if (cursor) { + query = query.where((eb) => + eb.or([ + eb(totalProgressExpr, "<", cursor.totalProgress), + eb.and([ + eb(totalProgressExpr, "=", cursor.totalProgress), + eb( + eb.fn.coalesce("l.english_name", sql.lit("")), + ">", + cursor.languageName, + ), + ]), + ]), + ); + } + + const languageRows = await query.execute(); + + const hasNextPage = languageRows.length > limit; + const languages = languageRows.slice(0, limit); + + return { + languages, + hasNextPage, + }; +} + +async function queryLanguageActivityRows({ + languageIds, + rangeDays, + granularity, +}: { + languageIds: string[]; + rangeDays: number; + granularity: "day" | "week"; +}) { + const activityRows = await getDb() + .with("event", (db) => + db + .selectFrom("gloss_event") + .whereRef("prev_state", "<>", "new_state") + .where("language_id", "in", languageIds) + .where( + "timestamp", + ">=", + sql`now() - (${rangeDays} || ' days')::INTERVAL`, + ) + .select([ + "language_id", + (eb) => + eb + .fn("date_trunc", [ + sql.lit(granularity), + eb.ref("timestamp"), + ]) + .as("date"), + (eb) => + eb + .case() + .when(eb.ref("new_state"), "=", GlossStateRaw.Approved) + .then(1) + .else(-1) + .end() + .as("delta"), + ]), + ) + .selectFrom("event") + .groupBy(["event.language_id", "event.date"]) + .select([ + "event.language_id as languageId", + "event.date", + (eb) => eb.fn.sum("event.delta").as("net"), + ]) + .orderBy("event.language_id") + .orderBy("event.date") + .execute(); + + const activityMap = new Map< + string, + PlatformDashboardLanguageActivityEntryReadModel[] + >(); + const activityTotalMap = new Map(); + + for (const activityRow of activityRows) { + const currentActivity = activityMap.get(activityRow.languageId) ?? []; + currentActivity.push({ date: activityRow.date, net: activityRow.net }); + activityMap.set(activityRow.languageId, currentActivity); + + const currentTotal = activityTotalMap.get(activityRow.languageId) ?? 0; + activityTotalMap.set( + activityRow.languageId, + currentTotal + activityRow.net, + ); + } + + return { activityMap, activityTotalMap }; +} + +interface CursorData { + totalProgress: number; + languageName: string; +} + +function encodeCursor(cursor: CursorData): string { + return Buffer.from(`${cursor.totalProgress}:${cursor.languageName}`).toString( + "base64url", + ); +} + +function decodeCursor(cursor: string): CursorData | null { + const cursorString = Buffer.from(cursor, "base64url").toString(); + const separatorIndex = cursorString.indexOf(":"); + if (separatorIndex === -1) { + return null; + } + + const totalProgress = Number.parseFloat( + cursorString.slice(0, separatorIndex), + ); + const languageName = cursorString.slice(separatorIndex + 1); + + if (!Number.isFinite(totalProgress)) { + return null; + } + + return { + totalProgress, + languageName, + }; +} diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx index 154292ab..c034b0d7 100644 --- a/src/ui/admin/routes/_main.dashboard.tsx +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -9,6 +9,9 @@ import LoadingSpinner from "@/components/LoadingSpinner"; import PlatformUsersDashboardCard, { platformDashboardContributorsInfiniteQueryOptions, } from "@/ui/admin/components/PlatformUsersDashboardCard"; +import PlatformLanguagesDashboardCard, { + platformDashboardLanguagesInfiniteQueryOptions, +} from "@/ui/admin/components/PlatformLanguagesDashboardCard"; const searchSchema = z.object({ range: z.enum(["30d", "6m"]).optional(), @@ -21,9 +24,14 @@ export const Route = createFileRoute("/_main/admin/_main/dashboard")({ const range = parsedSearch.success ? (parsedSearch.data.range ?? "30d") : "30d"; - await context.queryClient.ensureInfiniteQueryData( - platformDashboardContributorsInfiniteQueryOptions(range), - ); + await Promise.all([ + context.queryClient.ensureInfiniteQueryData( + platformDashboardContributorsInfiniteQueryOptions(range), + ), + context.queryClient.ensureInfiniteQueryData( + platformDashboardLanguagesInfiniteQueryOptions(range), + ), + ]); }, head: () => withDocumentTitle("Dashboard | Admin"), pendingComponent: AdminDashboardRoutePending, @@ -54,6 +62,9 @@ function AdminDashboardRoute() { await queryClient.ensureInfiniteQueryData( platformDashboardContributorsInfiniteQueryOptions(nextRange), ); + await queryClient.ensureInfiniteQueryData( + platformDashboardLanguagesInfiniteQueryOptions(nextRange), + ); navigate({ to: ".", @@ -68,8 +79,9 @@ function AdminDashboardRoute() { -
+
+
diff --git a/src/ui/admin/serverFns/getPlatformDashboardLanguages.ts b/src/ui/admin/serverFns/getPlatformDashboardLanguages.ts new file mode 100644 index 00000000..8162c7b6 --- /dev/null +++ b/src/ui/admin/serverFns/getPlatformDashboardLanguages.ts @@ -0,0 +1,25 @@ +import { createPolicyMiddleware, Policy } from "@/modules/access"; +import { createServerFn } from "@tanstack/react-start"; +import * as z from "zod"; +import { getPlatformDashboardLanguagesReadModel } from "@/ui/admin/readModels/getPlatformDashboardLanguagesReadModel"; + +const policy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], +}); + +const requestSchema = z.object({ + range: z.enum(["30d", "6m"]), + cursor: z.string().optional(), + limit: z.coerce.number().int().positive().default(10), +}); + +export const getPlatformDashboardLanguages = createServerFn() + .inputValidator(requestSchema) + .middleware([createPolicyMiddleware({ policy })]) + .handler(async ({ data }) => { + return getPlatformDashboardLanguagesReadModel({ + range: data.range, + limit: data.limit, + cursor: data.cursor, + }); + }); From 9983697220efe35df86534faebdc7e1965243fbb Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 21:15:25 -0500 Subject: [PATCH 2/2] feat: dashboard navigation links --- src/ui/admin/routes/_main.dashboard.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx index c034b0d7..8366810c 100644 --- a/src/ui/admin/routes/_main.dashboard.tsx +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -2,6 +2,8 @@ import * as z from "zod"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { withDocumentTitle } from "@/documentTitle"; +import Button from "@/components/Button"; +import { Icon } from "@/components/Icon"; import ViewTitle from "@/components/ViewTitle"; import RangeToggle from "@/ui/admin/components/RangeToggle"; import { ActivityChartProvider } from "@/ui/admin/components/ActivityChart"; @@ -56,6 +58,14 @@ function AdminDashboardRoute() {
Dashboard
+ + {