diff --git a/src/components/InfiniteFetchTrigger.tsx b/src/components/InfiniteFetchTrigger.tsx new file mode 100644 index 00000000..06baef8b --- /dev/null +++ b/src/components/InfiniteFetchTrigger.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; + +export default function InfiniteFetchTrigger({ + onTrigger, + loading, + hasMore, + className, + children, +}: { + onTrigger: () => void; + loading: boolean; + hasMore: boolean; + className?: string; + children: React.ReactNode; +}) { + const triggerRef = useRef(null); + + useEffect(() => { + if (!hasMore) { + return; + } + + const node = triggerRef.current; + if (!node) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !loading) { + onTrigger(); + } + }, + { + rootMargin: "200px", + }, + ); + + observer.observe(node); + + return () => observer.disconnect(); + }, [hasMore, loading, onTrigger]); + + if (!hasMore) return null; + + return ( + <> +
+ {children} +
+ + ); +} diff --git a/src/messages/ar.json b/src/messages/ar.json index d426436e..a26bfa43 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -40,6 +40,7 @@ "AdminLayout": { "title": "الإدارة", "links": { + "dashboard": "لوحة التحكم", "languages": "اللغات", "users": "المستخدمون" } diff --git a/src/messages/en.json b/src/messages/en.json index 7df6ddd6..2420dd07 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -40,6 +40,7 @@ "AdminLayout": { "title": "Admin", "links": { + "dashboard": "Dashboard", "languages": "Languages", "users": "Users" } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7fd88ae5..5c2db5d2 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteImport } from import { Route as mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRouteImport } from "./ui/translation/routes/$code.$verseId"; import { Route as mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRouteImport } from "./ui/study/routes/$code.$chapterId"; import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRouteImport } from "./ui/admin/routes/_main.jobs"; +import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport } from "./ui/admin/routes/_main.dashboard"; import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteImport } from "./ui/admin/routes/languages.$code/route"; import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeIndexRouteImport } from "./ui/admin/routes/languages.$code/index"; import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersIndexRouteImport } from "./ui/admin/routes/_main.users/index"; @@ -155,6 +156,12 @@ const mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute = path: "/jobs", getParentRoute: () => mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute, } as any); +const mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute = + mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport.update({ + id: "/dashboard", + path: "/dashboard", + getParentRoute: () => mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute, + } as any); const mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRoute = mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteImport.update( { @@ -246,6 +253,7 @@ export interface FileRoutesByFullPath { "/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren; "/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren; "/admin/languages/$code": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteWithChildren; + "/admin/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute; "/admin/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute; "/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute; "/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute; @@ -275,6 +283,7 @@ export interface FileRoutesByTo { "/p/$": typeof MainPSplatRoute; "/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren; "/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren; + "/admin/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute; "/admin/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute; "/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute; "/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute; @@ -308,6 +317,7 @@ export interface FileRoutesById { "/_main/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren; "/_main/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren; "/_main/admin/languages/$code": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteWithChildren; + "/_main/admin/_main/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute; "/_main/admin/_main/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute; "/_main/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute; "/_main/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute; @@ -340,6 +350,7 @@ export interface FileRouteTypes { | "/read/$code" | "/translate/$code" | "/admin/languages/$code" + | "/admin/dashboard" | "/admin/jobs" | "/read/$code/$chapterId" | "/translate/$code/$verseId" @@ -369,6 +380,7 @@ export interface FileRouteTypes { | "/p/$" | "/read/$code" | "/translate/$code" + | "/admin/dashboard" | "/admin/jobs" | "/read/$code/$chapterId" | "/translate/$code/$verseId" @@ -401,6 +413,7 @@ export interface FileRouteTypes { | "/_main/read/$code" | "/_main/translate/$code" | "/_main/admin/languages/$code" + | "/_main/admin/_main/dashboard" | "/_main/admin/_main/jobs" | "/_main/read/$code/$chapterId" | "/_main/translate/$code/$verseId" @@ -570,6 +583,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRouteImport; parentRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute; }; + "/_main/admin/_main/dashboard": { + id: "/_main/admin/_main/dashboard"; + path: "/dashboard"; + fullPath: "/admin/dashboard"; + preLoaderRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport; + parentRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute; + }; "/_main/admin/languages/$code": { id: "/_main/admin/languages/$code"; path: "/admin/languages/$code"; @@ -697,6 +717,7 @@ const mainTranslateDotDotDotDotDotDotUiTranslationRoutesRouteRouteWithChildren = ); interface mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren { + mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute; mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute; mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute; mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersInviteRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersInviteRoute; @@ -706,6 +727,8 @@ interface mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren { const mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren: mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren = { + mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute: + mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute, mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute: mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute, mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute: diff --git a/src/ui/admin/components/PlatformUsersDashboardCard.tsx b/src/ui/admin/components/PlatformUsersDashboardCard.tsx new file mode 100644 index 00000000..b9486ed4 --- /dev/null +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -0,0 +1,147 @@ +import { useMemo } from "react"; +import { type ActivityChartRange } from "./ActivityChart"; +import ActivityChart from "./ActivityChart"; +import ContributionBar from "./ContributionBar"; +import ServerAction from "@/components/ServerAction"; +import { Icon } from "@/components/Icon"; +import { disableUser } from "@/modules/users/actions/disableUser"; +import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"; +import { + DashboardCard, + DashboardCardEmptyState, + DashboardCardHeader, +} from "./DashboardCard"; +import StatusBadge from "./StatusBadge"; +import { getPlatformDashboardContributors } from "@/ui/admin/serverFns/getPlatformDashboardContributors"; +import InfiniteFetchTrigger from "@/components/InfiniteFetchTrigger"; + +const PAGE_SIZE = 10; + +export function platformDashboardContributorsInfiniteQueryOptions( + range: ActivityChartRange, +) { + return infiniteQueryOptions({ + queryKey: ["platformDashboardContributors", range], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + getPlatformDashboardContributors({ + data: { + range, + limit: PAGE_SIZE, + cursor: pageParam, + }, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); +} + +export default function PlatformUsersDashboardCard({ + className = "", + range, +}: { + className?: string; + range: ActivityChartRange; +}) { + const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery(platformDashboardContributorsInfiniteQueryOptions(range)); + + const fullUsers = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data], + ); + + const yMin = fullUsers.reduce( + (min, user) => + user.activity.reduce( + (entryMin, entry) => Math.min(entryMin, entry.net), + min, + ), + 0, + ); + const yMax = fullUsers.reduce( + (max, user) => + user.activity.reduce( + (entryMax, entry) => Math.max(entryMax, entry.net), + max, + ), + 0, + ); + const maxContribution = fullUsers.reduce( + (max, user) => Math.max(max, user.contributedGlosses), + 0, + ); + + return ( + + +
+ {fullUsers.length === 0 ? + No users found. + :
+ {fullUsers.map((user) => { + return ( +
+
+

+ {user.name ?? user.email} +

+ {user.status === "invited" && ( + Invited + )} +
+ + + Disable + +
+
+
+ +
+ + {user.contributedGlosses.toLocaleString()} glosses + +
+
+ +
+ ); + })} + + { + void fetchNextPage(); + }} + > + Loading more contributors... + +
+ } +
+
+ ); +} diff --git a/src/ui/admin/readModels/getPlatformDashboardContributorsReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardContributorsReadModel.ts new file mode 100644 index 00000000..cde60998 --- /dev/null +++ b/src/ui/admin/readModels/getPlatformDashboardContributorsReadModel.ts @@ -0,0 +1,265 @@ +import { getDb } from "@/db"; +import { UserStatusRaw } from "@/modules/users/model/UserStatus"; +import { GlossStateRaw } from "@/modules/translation/types"; +import { sql } from "kysely"; + +export interface PlatformDashboardContributorActivityEntryReadModel { + date: Date; + net: number; +} + +export interface PlatformDashboardContributorReadModel { + id: string; + name: string | null; + email: string; + status: "active" | "invited"; + contributedGlosses: number; + activityTotal: number; + activity: PlatformDashboardContributorActivityEntryReadModel[]; +} + +export interface PlatformDashboardContributorsReadModel { + items: PlatformDashboardContributorReadModel[]; + nextCursor: string | null; +} + +export async function getPlatformDashboardContributorsReadModel({ + 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 { users, hasNextPage } = await queryContributorPageRows({ + limit, + cursor: parsedCursor, + }); + + if (users.length === 0) { + return { + items: [], + nextCursor: null, + }; + } + + const { activityMap, activityTotalMap } = await queryContributorActivityRows({ + userIds: users.map((row) => row.id), + rangeDays, + granularity, + }); + + const lastRow = users[users.length - 1]; + const nextCursor = + hasNextPage && lastRow ? + encodeCursor({ + contributedGlosses: lastRow.contributedGlosses, + userName: lastRow.name ?? "", + }) + : null; + + return { + items: users.map((row) => ({ + id: row.id, + name: row.name, + email: row.email, + status: row.status, + contributedGlosses: row.contributedGlosses, + activity: activityMap.get(row.id) ?? [], + activityTotal: activityTotalMap.get(row.id) ?? 0, + })), + nextCursor, + }; +} + +async function queryContributorPageRows({ + limit, + cursor, +}: { + limit: number; + cursor: CursorData | null; +}) { + let query = getDb() + .with("invited_user", (db) => + db.selectFrom("user_invitation").select("user_id").distinct(), + ) + .with("contribution", (db) => + db + .selectFrom("book_completion_progress") + .where("book_completion_progress.user_id", "is not", null) + .groupBy("book_completion_progress.user_id") + .select([ + (eb) => + eb.ref("book_completion_progress.user_id").$notNull().as("userId"), + (eb) => + eb.fn + .sum("book_completion_progress.word_count") + .as("approvedGlossCount"), + ]), + ) + .selectFrom("users") + .leftJoin("invited_user", "invited_user.user_id", "users.id") + .leftJoin("contribution", "contribution.userId", "users.id") + .where("users.status", "<>", UserStatusRaw.Disabled) + .select([ + "users.id", + "users.name", + "users.email", + (eb) => + eb + .case() + .when("invited_user.user_id", "is not", null) + .then<"active" | "invited">("invited") + .else<"active" | "invited">("active") + .end() + .as("status"), + (eb) => + eb.fn + .coalesce("contribution.approvedGlossCount", eb.lit(0)) + .as("contributedGlosses"), + ]) + .orderBy( + (eb) => eb.fn.coalesce("contribution.approvedGlossCount", eb.lit(0)), + "desc", + ) + .orderBy((eb) => eb.fn.coalesce("users.name", sql.lit(""))) + .orderBy("users.id") + .limit(limit + 1); + + if (cursor) { + query = query.where((eb) => + eb.or([ + eb( + eb.fn.coalesce("contribution.approvedGlossCount", eb.lit(0)), + "<", + cursor.contributedGlosses, + ), + eb.and([ + eb( + eb.fn.coalesce("contribution.approvedGlossCount", eb.lit(0)), + "=", + cursor.contributedGlosses, + ), + eb(eb.fn.coalesce("users.name", sql.lit("")), ">", cursor.userName), + ]), + ]), + ); + } + + const userRows = await query.execute(); + + const hasNextPage = userRows.length > limit; + const users = userRows.slice(0, limit); + + return { + users, + hasNextPage, + }; +} + +async function queryContributorActivityRows({ + userIds, + rangeDays, + granularity, +}: { + userIds: string[]; + rangeDays: number; + granularity: "day" | "week"; +}) { + const activityRows = await getDb() + .with("event", (db) => + db + .selectFrom("gloss_event") + .whereRef("prev_state", "<>", "new_state") + .where("user_id", "in", userIds) + .where( + "timestamp", + ">=", + sql`now() - (${rangeDays} || ' days')::INTERVAL`, + ) + .select([ + "user_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.user_id", "event.date"]) + .select([ + "event.user_id as userId", + "event.date", + (eb) => eb.fn.sum("event.delta").as("net"), + ]) + .orderBy("event.user_id") + .orderBy("event.date") + .execute(); + + const activityMap = new Map< + string, + PlatformDashboardContributorActivityEntryReadModel[] + >(); + const activityTotalMap = new Map(); + + for (const activityRow of activityRows) { + const currentActivity = activityMap.get(activityRow.userId) ?? []; + currentActivity.push({ date: activityRow.date, net: activityRow.net }); + activityMap.set(activityRow.userId, currentActivity); + + const currentTotal = activityTotalMap.get(activityRow.userId) ?? 0; + activityTotalMap.set(activityRow.userId, currentTotal + activityRow.net); + } + + return { activityMap, activityTotalMap }; +} + +interface CursorData { + contributedGlosses: number; + userName: string; +} + +function encodeCursor(cursor: CursorData): string { + return Buffer.from( + `${cursor.contributedGlosses}:${cursor.userName}`, + ).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 contributedGlosses = parseInt(cursorString.slice(0, separatorIndex)); + const userName = cursorString.slice(separatorIndex + 1); + + if (Number.isNaN(contributedGlosses)) { + return null; + } + + return { + contributedGlosses, + userName, + }; +} diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx new file mode 100644 index 00000000..154292ab --- /dev/null +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -0,0 +1,77 @@ +import * as z from "zod"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { withDocumentTitle } from "@/documentTitle"; +import ViewTitle from "@/components/ViewTitle"; +import RangeToggle from "@/ui/admin/components/RangeToggle"; +import { ActivityChartProvider } from "@/ui/admin/components/ActivityChart"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import PlatformUsersDashboardCard, { + platformDashboardContributorsInfiniteQueryOptions, +} from "@/ui/admin/components/PlatformUsersDashboardCard"; + +const searchSchema = z.object({ + range: z.enum(["30d", "6m"]).optional(), +}); + +export const Route = createFileRoute("/_main/admin/_main/dashboard")({ + validateSearch: searchSchema, + loader: async ({ context, location }) => { + const parsedSearch = searchSchema.safeParse(location.search); + const range = + parsedSearch.success ? (parsedSearch.data.range ?? "30d") : "30d"; + + await context.queryClient.ensureInfiniteQueryData( + platformDashboardContributorsInfiniteQueryOptions(range), + ); + }, + head: () => withDocumentTitle("Dashboard | Admin"), + pendingComponent: AdminDashboardRoutePending, + component: AdminDashboardRoute, +}); + +function AdminDashboardRoutePending() { + return ( +
+ +
+ ); +} + +function AdminDashboardRoute() { + const { range = "30d" } = Route.useSearch(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return ( +
+
+ Dashboard +
+ { + await queryClient.ensureInfiniteQueryData( + platformDashboardContributorsInfiniteQueryOptions(nextRange), + ); + + navigate({ + to: ".", + replace: true, + search: (prev) => ({ + ...prev, + range: nextRange, + }), + }); + }} + /> +
+ + +
+ +
+
+
+ ); +} diff --git a/src/ui/admin/routes/_main.tsx b/src/ui/admin/routes/_main.tsx index 1e845d7d..49d8e492 100644 --- a/src/ui/admin/routes/_main.tsx +++ b/src/ui/admin/routes/_main.tsx @@ -41,6 +41,12 @@ function AdminLayout() {

{t("title")}

    +
  • + + + {t("links.dashboard")} + +
  • diff --git a/src/ui/admin/serverFns/getPlatformDashboardContributors.ts b/src/ui/admin/serverFns/getPlatformDashboardContributors.ts new file mode 100644 index 00000000..65407317 --- /dev/null +++ b/src/ui/admin/serverFns/getPlatformDashboardContributors.ts @@ -0,0 +1,25 @@ +import { createPolicyMiddleware, Policy } from "@/modules/access"; +import { createServerFn } from "@tanstack/react-start"; +import * as z from "zod"; +import { getPlatformDashboardContributorsReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributorsReadModel"; + +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(20), +}); + +export const getPlatformDashboardContributors = createServerFn() + .inputValidator(requestSchema) + .middleware([createPolicyMiddleware({ policy })]) + .handler(async ({ data }) => { + return getPlatformDashboardContributorsReadModel({ + range: data.range, + limit: data.limit, + cursor: data.cursor, + }); + });