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
150 changes: 150 additions & 0 deletions src/ui/admin/components/PlatformLanguagesDashboardCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardCard className={className}>
<DashboardCardHeader title="Languages" />
<div className="flex-1 overflow-auto relative">
{languages.length === 0 ?
<DashboardCardEmptyState>No languages found.</DashboardCardEmptyState>
: <div className="divide-y divide-gray-200 dark:divide-gray-700 grid grid-cols-[1fr_1fr]">
{languages.map((language) => (
<div
key={language.code}
className="grid grid-cols-subgrid col-span-2 gap-y-1 gap-x-4 py-3 px-4 items-stretch"
>
<div className="min-w-0 col-span-2 flex items-baseline gap-1">
<Button
to="/admin/languages/$code"
variant="tertiary"
params={{ code: language.code }}
className="p-0 text-sm font-bold"
>
{language.englishName}
</Button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{language.code}
</span>
<div className="flex grow justify-end gap-3">
<Button
variant="tertiary"
to="/admin/languages/$code/settings"
params={{ code: language.code }}
>
<Icon icon="gear" size="sm" />
<span className="sr-only">Settings</span>
</Button>
</div>
</div>

<div className="min-w-0 self-end">
<div className="grid grid-cols-[auto_1fr_auto] gap-x-2 gap-y-2 items-center">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300">
OT
</div>
<ProgressBar progress={language.otProgress} />
<div className="text-xs tabular-nums text-gray-600 dark:text-gray-400">
{(language.otProgress * 100).toFixed(0)}%
</div>

<div className="text-xs font-semibold text-gray-600 dark:text-gray-300">
NT
</div>
<ProgressBar progress={language.ntProgress} />
<div className="text-xs tabular-nums text-gray-600 dark:text-gray-400">
{(language.ntProgress * 100).toFixed(0)}%
</div>
</div>
</div>

<ActivityChart
className="min-w-0 place-self-stretch h-12"
data={language.activity}
total={language.activityTotal}
yMin={yMin}
yMax={yMax}
range={range}
/>
</div>
))}

<InfiniteFetchTrigger
hasMore={hasNextPage ?? false}
loading={isFetchingNextPage}
className="col-span-2 px-4 py-2 text-sm text-gray-500 dark:text-gray-400"
onTrigger={() => {
void fetchNextPage();
}}
>
Loading more languages...
</InfiniteFetchTrigger>
</div>
}
</div>
</DashboardCard>
);
}
239 changes: 239 additions & 0 deletions src/ui/admin/readModels/getPlatformDashboardLanguagesReadModel.ts
Original file line number Diff line number Diff line change
@@ -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<PlatformDashboardLanguagesReadModel> {
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<number>`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<Date>`now() - (${rangeDays} || ' days')::INTERVAL`,
)
.select([
"language_id",
(eb) =>
eb
.fn<Date>("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<number>("event.delta").as("net"),
])
.orderBy("event.language_id")
.orderBy("event.date")
.execute();

const activityMap = new Map<
string,
PlatformDashboardLanguageActivityEntryReadModel[]
>();
const activityTotalMap = new Map<string, number>();

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,
};
}
Loading
Loading