From d49e93ccdfd75d70d4d3b339353c6a8131ed14ce Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 13:29:52 -0500 Subject: [PATCH 1/5] feat: all users dashboard card --- src/messages/ar.json | 1 + src/messages/en.json | 1 + src/routeTree.gen.ts | 23 +++ .../components/PlatformUsersDashboardCard.tsx | 148 ++++++++++++++++++ .../getPlatformDashboardActivityReadModel.ts | 57 +++++++ ...PlatformDashboardContributionsReadModel.ts | 25 +++ .../getPlatformDashboardUsersReadModel.ts | 20 +++ src/ui/admin/routes/_main.dashboard.tsx | 73 +++++++++ src/ui/admin/routes/_main.tsx | 6 + .../serverFns/getPlatformDashboardBaseData.ts | 22 +++ .../getPlatformDashboardRangeData.ts | 29 ++++ 11 files changed, 405 insertions(+) create mode 100644 src/ui/admin/components/PlatformUsersDashboardCard.tsx create mode 100644 src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts create mode 100644 src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts create mode 100644 src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts create mode 100644 src/ui/admin/routes/_main.dashboard.tsx create mode 100644 src/ui/admin/serverFns/getPlatformDashboardBaseData.ts create mode 100644 src/ui/admin/serverFns/getPlatformDashboardRangeData.ts 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..be7cd3e4 --- /dev/null +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -0,0 +1,148 @@ +import { useMemo } from "react"; +import { type ActivityChartRange } from "./ActivityChart"; +import ActivityChart from "./ActivityChart"; +import ContributionBar from "./ContributionBar"; +import { + DashboardCard, + DashboardCardEmptyState, + DashboardCardHeader, +} from "./DashboardCard"; +import { type PlatformDashboardUserReadModel } from "@/ui/admin/readModels/getPlatformDashboardUsersReadModel"; +import { type PlatformDashboardContributionReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributionsReadModel"; +import { type PlatformDashboardActivityEntryReadModel } from "@/ui/admin/readModels/getPlatformDashboardActivityReadModel"; + +export default function PlatformUsersDashboardCard({ + className = "", + users, + contributions, + activity, + range, +}: { + className?: string; + users: PlatformDashboardUserReadModel[]; + contributions: PlatformDashboardContributionReadModel[]; + activity: PlatformDashboardActivityEntryReadModel[]; + range: ActivityChartRange; +}) { + const fullUsers = useMemo(() => { + const contributionByUserId = new Map(); + for (const contribution of contributions) { + contributionByUserId.set( + contribution.userId, + contribution.approvedGlossCount, + ); + } + + const activityByUserId = new Map< + string, + { + total: number; + entries: Map; + } + >(); + + for (const activityEntry of activity) { + let userEntry = activityByUserId.get(activityEntry.userId); + if (!userEntry) { + userEntry = { + total: 0, + entries: new Map(), + }; + activityByUserId.set(activityEntry.userId, userEntry); + } + + const dateKey = activityEntry.date.valueOf(); + let dateEntry = userEntry.entries.get(dateKey); + if (!dateEntry) { + dateEntry = { date: activityEntry.date, net: 0 }; + userEntry.entries.set(dateKey, dateEntry); + } + + dateEntry.net += activityEntry.net; + userEntry.total += activityEntry.net; + } + + const rows = users.map((user) => { + const activity = activityByUserId.get(user.id); + const contributedGlosses = contributionByUserId.get(user.id) ?? 0; + + return { + ...user, + contributedGlosses, + activity: Array.from(activity?.entries.values() ?? []), + activityTotal: activity?.total ?? 0, + }; + }); + + rows.sort((a, b) => b.contributedGlosses - a.contributedGlosses); + + return rows; + }, [users, contributions, activity]); + + 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 ( + + +
+ {users.length === 0 ? + No users found. + :
+ {fullUsers.map((user) => { + return ( +
+
+

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

+
+
+ +
+ + {user.contributedGlosses.toLocaleString()} glosses + +
+
+ +
+ ); + })} +
+ } +
+
+ ); +} diff --git a/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts new file mode 100644 index 00000000..a54dbb04 --- /dev/null +++ b/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts @@ -0,0 +1,57 @@ +import { sql } from "kysely"; +import { getDb } from "@/db"; +import { GlossStateRaw } from "@/modules/translation/types"; + +export interface PlatformDashboardActivityEntryReadModel { + userId: string; + date: Date; + net: number; +} + +export async function getPlatformDashboardActivityReadModel({ + granularity, + range, +}: { + granularity: "day" | "week"; + range: number; +}): Promise { + return getDb() + .with("event", (db) => + db + .selectFrom("gloss_event") + .whereRef("prev_state", "<>", "new_state") + .where( + "timestamp", + ">=", + sql`now() - (${range} || ' 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(); +} diff --git a/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts new file mode 100644 index 00000000..44b65950 --- /dev/null +++ b/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts @@ -0,0 +1,25 @@ +import { getDb } from "@/db"; + +export interface PlatformDashboardContributionReadModel { + userId: string; + approvedGlossCount: number; +} + +export async function getPlatformDashboardContributionsReadModel(): Promise< + PlatformDashboardContributionReadModel[] +> { + return getDb() + .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"), + ]) + .orderBy("approvedGlossCount", "desc") + .execute(); +} diff --git a/src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts new file mode 100644 index 00000000..d9ac6f07 --- /dev/null +++ b/src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts @@ -0,0 +1,20 @@ +import { getDb } from "@/db"; +import { UserStatusRaw } from "@/modules/users/model/UserStatus"; + +export interface PlatformDashboardUserReadModel { + id: string; + name: string | null; + email: string; +} + +export async function getPlatformDashboardUsersReadModel(): Promise< + PlatformDashboardUserReadModel[] +> { + return getDb() + .selectFrom("users") + .where("users.status", "<>", UserStatusRaw.Disabled) + .select(["users.id", "users.name", "users.email"]) + .orderBy("users.name") + .orderBy("users.id") + .execute(); +} diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx new file mode 100644 index 00000000..d2580b49 --- /dev/null +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -0,0 +1,73 @@ +import * as z from "zod"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { withDocumentTitle } from "@/documentTitle"; +import ViewTitle from "@/components/ViewTitle"; +import RangeToggle from "@/ui/admin/components/RangeToggle"; +import { ActivityChartProvider } from "@/ui/admin/components/ActivityChart"; +import PlatformUsersDashboardCard from "@/ui/admin/components/PlatformUsersDashboardCard"; +import { getPlatformDashboardBaseData } from "@/ui/admin/serverFns/getPlatformDashboardBaseData"; +import { getPlatformDashboardRangeData } from "@/ui/admin/serverFns/getPlatformDashboardRangeData"; + +const searchSchema = z.object({ + range: z.enum(["30d", "6m"]).optional(), +}); + +export const Route = createFileRoute("/_main/admin/_main/dashboard")({ + validateSearch: searchSchema, + loader: async () => { + const [baseData, range30dData, range6mData] = await Promise.all([ + getPlatformDashboardBaseData(), + getPlatformDashboardRangeData({ data: { range: "30d" } }), + getPlatformDashboardRangeData({ data: { range: "6m" } }), + ]); + + return { + ...baseData, + activityByRange: { + "30d": range30dData, + "6m": range6mData, + }, + }; + }, + head: () => withDocumentTitle("Dashboard | Admin"), + component: AdminDashboardRoute, +}); + +function AdminDashboardRoute() { + const { users, contributions, activityByRange } = Route.useLoaderData(); + const { range = "30d" } = Route.useSearch(); + const navigate = useNavigate(); + + return ( +
+
+ Dashboard +
+ { + 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/getPlatformDashboardBaseData.ts b/src/ui/admin/serverFns/getPlatformDashboardBaseData.ts new file mode 100644 index 00000000..086aace9 --- /dev/null +++ b/src/ui/admin/serverFns/getPlatformDashboardBaseData.ts @@ -0,0 +1,22 @@ +import { createPolicyMiddleware, Policy } from "@/modules/access"; +import { getPlatformDashboardContributionsReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributionsReadModel"; +import { getPlatformDashboardUsersReadModel } from "@/ui/admin/readModels/getPlatformDashboardUsersReadModel"; +import { createServerFn } from "@tanstack/react-start"; + +const policy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], +}); + +export const getPlatformDashboardBaseData = createServerFn() + .middleware([createPolicyMiddleware({ policy })]) + .handler(async () => { + const [users, contributions] = await Promise.all([ + getPlatformDashboardUsersReadModel(), + getPlatformDashboardContributionsReadModel(), + ]); + + return { + users, + contributions, + }; + }); diff --git a/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts b/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts new file mode 100644 index 00000000..f5e2fc4e --- /dev/null +++ b/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts @@ -0,0 +1,29 @@ +import { createPolicyMiddleware, Policy } from "@/modules/access"; +import { getPlatformDashboardActivityReadModel } from "@/ui/admin/readModels/getPlatformDashboardActivityReadModel"; +import { createServerFn } from "@tanstack/react-start"; +import * as z from "zod"; + +const policy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], +}); + +const requestSchema = z.object({ + range: z.enum(["30d", "6m"]), +}); + +export const getPlatformDashboardRangeData = createServerFn() + .inputValidator(requestSchema) + .middleware([createPolicyMiddleware({ policy })]) + .handler(async ({ data }) => { + const granularity = data.range === "30d" ? "day" : "week"; + const range = data.range === "30d" ? 30 : 182; + + const activity = await getPlatformDashboardActivityReadModel({ + granularity, + range, + }); + + return { + activity, + }; + }); From 3a141ab114d41a399d4f066b30b4cb784fb79256 Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 13:32:02 -0500 Subject: [PATCH 2/5] feat: disable user from admin dashboard --- .../components/PlatformUsersDashboardCard.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui/admin/components/PlatformUsersDashboardCard.tsx b/src/ui/admin/components/PlatformUsersDashboardCard.tsx index be7cd3e4..f27cc5f7 100644 --- a/src/ui/admin/components/PlatformUsersDashboardCard.tsx +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -2,6 +2,9 @@ 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 { DashboardCard, DashboardCardEmptyState, @@ -117,6 +120,20 @@ export default function PlatformUsersDashboardCard({

    {user.name ?? user.email}

    +
    + + + Disable + +
    Date: Fri, 8 May 2026 13:36:22 -0500 Subject: [PATCH 3/5] feat: mark invited users in dashboard --- .../components/PlatformUsersDashboardCard.tsx | 4 ++++ .../getPlatformDashboardUsersReadModel.ts | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ui/admin/components/PlatformUsersDashboardCard.tsx b/src/ui/admin/components/PlatformUsersDashboardCard.tsx index f27cc5f7..d31c0310 100644 --- a/src/ui/admin/components/PlatformUsersDashboardCard.tsx +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -10,6 +10,7 @@ import { DashboardCardEmptyState, DashboardCardHeader, } from "./DashboardCard"; +import StatusBadge from "./StatusBadge"; import { type PlatformDashboardUserReadModel } from "@/ui/admin/readModels/getPlatformDashboardUsersReadModel"; import { type PlatformDashboardContributionReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributionsReadModel"; import { type PlatformDashboardActivityEntryReadModel } from "@/ui/admin/readModels/getPlatformDashboardActivityReadModel"; @@ -120,6 +121,9 @@ export default function PlatformUsersDashboardCard({

    {user.name ?? user.email}

    + {user.status === "invited" && ( + Invited + )}
    { return getDb() + .with("invited_user", (db) => + db.selectFrom("user_invitation").select("user_id").distinct(), + ) .selectFrom("users") + .leftJoin("invited_user", "invited_user.user_id", "users.id") .where("users.status", "<>", UserStatusRaw.Disabled) - .select(["users.id", "users.name", "users.email"]) + .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"), + ]) .orderBy("users.name") .orderBy("users.id") .execute(); From 91e76d7e1c3da9e204f58ae0c95dba6ba741d2e3 Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 17:14:51 -0500 Subject: [PATCH 4/5] feat: infinite scroll to fetch contributors --- src/components/InfiniteFetchTrigger.tsx | 53 ++++ .../components/PlatformUsersDashboardCard.tsx | 98 +++---- .../getPlatformDashboardActivityReadModel.ts | 57 ---- ...PlatformDashboardContributionsReadModel.ts | 25 -- ...tPlatformDashboardContributorsReadModel.ts | 265 ++++++++++++++++++ .../getPlatformDashboardUsersReadModel.ts | 37 --- src/ui/admin/routes/_main.dashboard.tsx | 25 +- .../serverFns/getPlatformDashboardBaseData.ts | 22 -- .../getPlatformDashboardContributors.ts | 25 ++ .../getPlatformDashboardRangeData.ts | 29 -- 10 files changed, 380 insertions(+), 256 deletions(-) create mode 100644 src/components/InfiniteFetchTrigger.tsx delete mode 100644 src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts delete mode 100644 src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts create mode 100644 src/ui/admin/readModels/getPlatformDashboardContributorsReadModel.ts delete mode 100644 src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts delete mode 100644 src/ui/admin/serverFns/getPlatformDashboardBaseData.ts create mode 100644 src/ui/admin/serverFns/getPlatformDashboardContributors.ts delete mode 100644 src/ui/admin/serverFns/getPlatformDashboardRangeData.ts 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/ui/admin/components/PlatformUsersDashboardCard.tsx b/src/ui/admin/components/PlatformUsersDashboardCard.tsx index d31c0310..ac360fe5 100644 --- a/src/ui/admin/components/PlatformUsersDashboardCard.tsx +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -5,83 +5,42 @@ import ContributionBar from "./ContributionBar"; import ServerAction from "@/components/ServerAction"; import { Icon } from "@/components/Icon"; import { disableUser } from "@/modules/users/actions/disableUser"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { DashboardCard, DashboardCardEmptyState, DashboardCardHeader, } from "./DashboardCard"; import StatusBadge from "./StatusBadge"; -import { type PlatformDashboardUserReadModel } from "@/ui/admin/readModels/getPlatformDashboardUsersReadModel"; -import { type PlatformDashboardContributionReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributionsReadModel"; -import { type PlatformDashboardActivityEntryReadModel } from "@/ui/admin/readModels/getPlatformDashboardActivityReadModel"; +import { getPlatformDashboardContributors } from "@/ui/admin/serverFns/getPlatformDashboardContributors"; +import InfiniteFetchTrigger from "@/components/InfiniteFetchTrigger"; export default function PlatformUsersDashboardCard({ className = "", - users, - contributions, - activity, range, }: { className?: string; - users: PlatformDashboardUserReadModel[]; - contributions: PlatformDashboardContributionReadModel[]; - activity: PlatformDashboardActivityEntryReadModel[]; range: ActivityChartRange; }) { - const fullUsers = useMemo(() => { - const contributionByUserId = new Map(); - for (const contribution of contributions) { - contributionByUserId.set( - contribution.userId, - contribution.approvedGlossCount, - ); - } - - const activityByUserId = new Map< - string, - { - total: number; - entries: Map; - } - >(); - - for (const activityEntry of activity) { - let userEntry = activityByUserId.get(activityEntry.userId); - if (!userEntry) { - userEntry = { - total: 0, - entries: new Map(), - }; - activityByUserId.set(activityEntry.userId, userEntry); - } - - const dateKey = activityEntry.date.valueOf(); - let dateEntry = userEntry.entries.get(dateKey); - if (!dateEntry) { - dateEntry = { date: activityEntry.date, net: 0 }; - userEntry.entries.set(dateKey, dateEntry); - } - - dateEntry.net += activityEntry.net; - userEntry.total += activityEntry.net; - } - - const rows = users.map((user) => { - const activity = activityByUserId.get(user.id); - const contributedGlosses = contributionByUserId.get(user.id) ?? 0; - - return { - ...user, - contributedGlosses, - activity: Array.from(activity?.entries.values() ?? []), - activityTotal: activity?.total ?? 0, - }; + const { data, isPending, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery({ + queryKey: ["platformDashboardContributors", range], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam }) => + getPlatformDashboardContributors({ + data: { + range, + limit: 10, + cursor: pageParam, + }, + }), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); - rows.sort((a, b) => b.contributedGlosses - a.contributedGlosses); - - return rows; - }, [users, contributions, activity]); + const fullUsers = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data], + ); const yMin = fullUsers.reduce( (min, user) => @@ -108,7 +67,11 @@ export default function PlatformUsersDashboardCard({
    - {users.length === 0 ? + {isPending ? + + Loading contributors... + + : fullUsers.length === 0 ? No users found. :
    {fullUsers.map((user) => { @@ -161,6 +124,17 @@ export default function PlatformUsersDashboardCard({
    ); })} + + { + void fetchNextPage(); + }} + > + Loading more contributors... +
    }
    diff --git a/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts deleted file mode 100644 index a54dbb04..00000000 --- a/src/ui/admin/readModels/getPlatformDashboardActivityReadModel.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { sql } from "kysely"; -import { getDb } from "@/db"; -import { GlossStateRaw } from "@/modules/translation/types"; - -export interface PlatformDashboardActivityEntryReadModel { - userId: string; - date: Date; - net: number; -} - -export async function getPlatformDashboardActivityReadModel({ - granularity, - range, -}: { - granularity: "day" | "week"; - range: number; -}): Promise { - return getDb() - .with("event", (db) => - db - .selectFrom("gloss_event") - .whereRef("prev_state", "<>", "new_state") - .where( - "timestamp", - ">=", - sql`now() - (${range} || ' 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(); -} diff --git a/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts deleted file mode 100644 index 44b65950..00000000 --- a/src/ui/admin/readModels/getPlatformDashboardContributionsReadModel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getDb } from "@/db"; - -export interface PlatformDashboardContributionReadModel { - userId: string; - approvedGlossCount: number; -} - -export async function getPlatformDashboardContributionsReadModel(): Promise< - PlatformDashboardContributionReadModel[] -> { - return getDb() - .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"), - ]) - .orderBy("approvedGlossCount", "desc") - .execute(); -} 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/readModels/getPlatformDashboardUsersReadModel.ts b/src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts deleted file mode 100644 index bea01650..00000000 --- a/src/ui/admin/readModels/getPlatformDashboardUsersReadModel.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getDb } from "@/db"; -import { UserStatusRaw } from "@/modules/users/model/UserStatus"; - -export interface PlatformDashboardUserReadModel { - id: string; - name: string | null; - email: string; - status: "active" | "invited"; -} - -export async function getPlatformDashboardUsersReadModel(): Promise< - PlatformDashboardUserReadModel[] -> { - return getDb() - .with("invited_user", (db) => - db.selectFrom("user_invitation").select("user_id").distinct(), - ) - .selectFrom("users") - .leftJoin("invited_user", "invited_user.user_id", "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"), - ]) - .orderBy("users.name") - .orderBy("users.id") - .execute(); -} diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx index d2580b49..eb23898d 100644 --- a/src/ui/admin/routes/_main.dashboard.tsx +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -5,8 +5,6 @@ import ViewTitle from "@/components/ViewTitle"; import RangeToggle from "@/ui/admin/components/RangeToggle"; import { ActivityChartProvider } from "@/ui/admin/components/ActivityChart"; import PlatformUsersDashboardCard from "@/ui/admin/components/PlatformUsersDashboardCard"; -import { getPlatformDashboardBaseData } from "@/ui/admin/serverFns/getPlatformDashboardBaseData"; -import { getPlatformDashboardRangeData } from "@/ui/admin/serverFns/getPlatformDashboardRangeData"; const searchSchema = z.object({ range: z.enum(["30d", "6m"]).optional(), @@ -14,27 +12,11 @@ const searchSchema = z.object({ export const Route = createFileRoute("/_main/admin/_main/dashboard")({ validateSearch: searchSchema, - loader: async () => { - const [baseData, range30dData, range6mData] = await Promise.all([ - getPlatformDashboardBaseData(), - getPlatformDashboardRangeData({ data: { range: "30d" } }), - getPlatformDashboardRangeData({ data: { range: "6m" } }), - ]); - - return { - ...baseData, - activityByRange: { - "30d": range30dData, - "6m": range6mData, - }, - }; - }, head: () => withDocumentTitle("Dashboard | Admin"), component: AdminDashboardRoute, }); function AdminDashboardRoute() { - const { users, contributions, activityByRange } = Route.useLoaderData(); const { range = "30d" } = Route.useSearch(); const navigate = useNavigate(); @@ -60,12 +42,7 @@ function AdminDashboardRoute() {
    - +
    diff --git a/src/ui/admin/serverFns/getPlatformDashboardBaseData.ts b/src/ui/admin/serverFns/getPlatformDashboardBaseData.ts deleted file mode 100644 index 086aace9..00000000 --- a/src/ui/admin/serverFns/getPlatformDashboardBaseData.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createPolicyMiddleware, Policy } from "@/modules/access"; -import { getPlatformDashboardContributionsReadModel } from "@/ui/admin/readModels/getPlatformDashboardContributionsReadModel"; -import { getPlatformDashboardUsersReadModel } from "@/ui/admin/readModels/getPlatformDashboardUsersReadModel"; -import { createServerFn } from "@tanstack/react-start"; - -const policy = new Policy({ - systemRoles: [Policy.SystemRole.Admin], -}); - -export const getPlatformDashboardBaseData = createServerFn() - .middleware([createPolicyMiddleware({ policy })]) - .handler(async () => { - const [users, contributions] = await Promise.all([ - getPlatformDashboardUsersReadModel(), - getPlatformDashboardContributionsReadModel(), - ]); - - return { - users, - contributions, - }; - }); 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, + }); + }); diff --git a/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts b/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts deleted file mode 100644 index f5e2fc4e..00000000 --- a/src/ui/admin/serverFns/getPlatformDashboardRangeData.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createPolicyMiddleware, Policy } from "@/modules/access"; -import { getPlatformDashboardActivityReadModel } from "@/ui/admin/readModels/getPlatformDashboardActivityReadModel"; -import { createServerFn } from "@tanstack/react-start"; -import * as z from "zod"; - -const policy = new Policy({ - systemRoles: [Policy.SystemRole.Admin], -}); - -const requestSchema = z.object({ - range: z.enum(["30d", "6m"]), -}); - -export const getPlatformDashboardRangeData = createServerFn() - .inputValidator(requestSchema) - .middleware([createPolicyMiddleware({ policy })]) - .handler(async ({ data }) => { - const granularity = data.range === "30d" ? "day" : "week"; - const range = data.range === "30d" ? 30 : 182; - - const activity = await getPlatformDashboardActivityReadModel({ - granularity, - range, - }); - - return { - activity, - }; - }); From ea5a47277784ea4700323750711ba58737dca5de Mon Sep 17 00:00:00 2001 From: Adrian Rocke Date: Fri, 8 May 2026 17:34:47 -0500 Subject: [PATCH 5/5] feat: prefetch data on route changes --- .../components/PlatformUsersDashboardCard.tsx | 44 ++++++++++--------- src/ui/admin/routes/_main.dashboard.tsx | 31 ++++++++++++- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/ui/admin/components/PlatformUsersDashboardCard.tsx b/src/ui/admin/components/PlatformUsersDashboardCard.tsx index ac360fe5..b9486ed4 100644 --- a/src/ui/admin/components/PlatformUsersDashboardCard.tsx +++ b/src/ui/admin/components/PlatformUsersDashboardCard.tsx @@ -5,7 +5,7 @@ import ContributionBar from "./ContributionBar"; import ServerAction from "@/components/ServerAction"; import { Icon } from "@/components/Icon"; import { disableUser } from "@/modules/users/actions/disableUser"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"; import { DashboardCard, DashboardCardEmptyState, @@ -15,6 +15,26 @@ 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, @@ -22,20 +42,8 @@ export default function PlatformUsersDashboardCard({ className?: string; range: ActivityChartRange; }) { - const { data, isPending, isFetchingNextPage, fetchNextPage, hasNextPage } = - useInfiniteQuery({ - queryKey: ["platformDashboardContributors", range], - initialPageParam: undefined as string | undefined, - queryFn: ({ pageParam }) => - getPlatformDashboardContributors({ - data: { - range, - limit: 10, - cursor: pageParam, - }, - }), - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }); + const { data, isFetchingNextPage, fetchNextPage, hasNextPage } = + useInfiniteQuery(platformDashboardContributorsInfiniteQueryOptions(range)); const fullUsers = useMemo( () => data?.pages.flatMap((page) => page.items) ?? [], @@ -67,11 +75,7 @@ export default function PlatformUsersDashboardCard({
    - {isPending ? - - Loading contributors... - - : fullUsers.length === 0 ? + {fullUsers.length === 0 ? No users found. :
    {fullUsers.map((user) => { diff --git a/src/ui/admin/routes/_main.dashboard.tsx b/src/ui/admin/routes/_main.dashboard.tsx index eb23898d..154292ab 100644 --- a/src/ui/admin/routes/_main.dashboard.tsx +++ b/src/ui/admin/routes/_main.dashboard.tsx @@ -1,10 +1,14 @@ 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 PlatformUsersDashboardCard from "@/ui/admin/components/PlatformUsersDashboardCard"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import PlatformUsersDashboardCard, { + platformDashboardContributorsInfiniteQueryOptions, +} from "@/ui/admin/components/PlatformUsersDashboardCard"; const searchSchema = z.object({ range: z.enum(["30d", "6m"]).optional(), @@ -12,13 +16,32 @@ const searchSchema = z.object({ 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 (
    @@ -27,7 +50,11 @@ function AdminDashboardRoute() {
    { + onChange={async (nextRange) => { + await queryClient.ensureInfiniteQueryData( + platformDashboardContributorsInfiniteQueryOptions(nextRange), + ); + navigate({ to: ".", replace: true,