diff --git a/server/src/routers/mentorship.ts b/server/src/routers/mentorship.ts index a34d14ff..eb55acb4 100644 --- a/server/src/routers/mentorship.ts +++ b/server/src/routers/mentorship.ts @@ -15,6 +15,7 @@ import { import { getAdminMembersOutputSchema, getAdminPairsOutputSchema, + getAdminStatsInputSchema, getPendingMentorsInputSchema, mentorshipAdminStatsOutputSchema, mentorshipDataOutputSchema, @@ -201,6 +202,7 @@ const updateMentorStatus = roleProcedure([GLOBAL_ADMIN_KEY]) ); const getAdminStats = roleProcedure([GLOBAL_ADMIN_KEY]) + .input(getAdminStatsInputSchema) .output(mentorshipAdminStatsOutputSchema) .meta({ openapi: { @@ -210,9 +212,9 @@ const getAdminStats = roleProcedure([GLOBAL_ADMIN_KEY]) tags: ["Mentorship"], }, }) - .query(() => + .query(({ input }) => withErrorHandling("getAdminStats", async () => { - return await mentorshipService.getAdminStats(); + return await mentorshipService.getAdminStats(input.days); }), ); diff --git a/server/src/service/mentorship-service.ts b/server/src/service/mentorship-service.ts index 6283510e..0541de78 100644 --- a/server/src/service/mentorship-service.ts +++ b/server/src/service/mentorship-service.ts @@ -473,24 +473,25 @@ export class MentorshipService { /** * Get admin statistics for the mentorship program */ - async getAdminStats(): Promise { + async getAdminStats(days = 30): Promise { const now = new Date(); - const thirtyDaysAgo = new Date(now); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const sixtyDaysAgo = new Date(now); - sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); + const periodStart = new Date(now); + periodStart.setDate(periodStart.getDate() - days); + const prevPeriodStart = new Date(now); + prevPeriodStart.setDate(prevPeriodStart.getDate() - days * 2); const [ mentorStats, menteeStats, matchRows, acceptingMentorsRow, - newMentorsLast30, - newMenteesLast30, - newMentorsPrev30, - newMenteesPrev30, + newMentorsThisPeriod, + newMenteesThisPeriod, + newMentorsPrevPeriod, + newMenteesPrevPeriod, mentorDailyRows, menteeDailyRows, + acceptedMatchesThisPeriod, ] = await Promise.all([ this.mentorRepo.getMentorStats(), this.menteeRepo.getMenteeStats(), @@ -507,54 +508,63 @@ export class MentorshipService { eq(mentors.isAcceptingNewMatches, true), ), ), - // new mentors last 30 days + // new mentors this period db .select({ value: count() }) .from(mentors) - .where(gte(mentors.createdAt, thirtyDaysAgo)), - // new mentees last 30 days + .where(gte(mentors.createdAt, periodStart)), + // new mentees this period db .select({ value: count() }) .from(mentees) - .where(gte(mentees.createdAt, thirtyDaysAgo)), - // new mentors previous 30 days + .where(gte(mentees.createdAt, periodStart)), + // new mentors previous period db .select({ value: count() }) .from(mentors) .where( and( - gte(mentors.createdAt, sixtyDaysAgo), - lt(mentors.createdAt, thirtyDaysAgo), + gte(mentors.createdAt, prevPeriodStart), + lt(mentors.createdAt, periodStart), ), ), - // new mentees previous 30 days + // new mentees previous period db .select({ value: count() }) .from(mentees) .where( and( - gte(mentees.createdAt, sixtyDaysAgo), - lt(mentees.createdAt, thirtyDaysAgo), + gte(mentees.createdAt, prevPeriodStart), + lt(mentees.createdAt, periodStart), ), ), - // daily mentor signups last 30 days + // daily mentor signups this period db .select({ date: sql`DATE(${mentors.createdAt})`, value: count(), }) .from(mentors) - .where(gte(mentors.createdAt, thirtyDaysAgo)) + .where(gte(mentors.createdAt, periodStart)) .groupBy(sql`DATE(${mentors.createdAt})`), - // daily mentee signups last 30 days + // daily mentee signups this period db .select({ date: sql`DATE(${mentees.createdAt})`, value: count(), }) .from(mentees) - .where(gte(mentees.createdAt, thirtyDaysAgo)) + .where(gte(mentees.createdAt, periodStart)) .groupBy(sql`DATE(${mentees.createdAt})`), + db + .select({ value: count() }) + .from(mentorshipMatches) + .where( + and( + eq(mentorshipMatches.status, "accepted"), + gte(mentorshipMatches.matchedAt, periodStart), + ), + ), ]); const matchCounts = { pending: 0, accepted: 0, declined: 0 }; @@ -581,14 +591,15 @@ export class MentorshipService { menteeDailyRows.map((r) => [r.date, Number(r.value)]), ); + const newMentors = Number(newMentorsThisPeriod[0]?.value ?? 0); + const newMentees = Number(newMenteesThisPeriod[0]?.value ?? 0); + // generate last 30 days as array of dates const dailyEnrollment = []; - let cumulativeMentors = - totalMentors - Number(newMentorsLast30[0]?.value ?? 0); - let cumulativeMentees = - totalMentees - Number(newMenteesLast30[0]?.value ?? 0); + let cumulativeMentors = totalMentors - newMentors; + let cumulativeMentees = totalMentees - newMentees; - for (let i = 29; i >= 0; i--) { + for (let i = days - 1; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); const dateStr = d.toISOString().split("T")[0] ?? ""; @@ -602,10 +613,8 @@ export class MentorshipService { } // calculate percent change vs previous 30 days - const newMentors = Number(newMentorsLast30[0]?.value ?? 0); - const newMentees = Number(newMenteesLast30[0]?.value ?? 0); - const prevMentors = Number(newMentorsPrev30[0]?.value ?? 0); - const prevMentees = Number(newMenteesPrev30[0]?.value ?? 0); + const prevMentors = Number(newMentorsPrevPeriod[0]?.value ?? 0); + const prevMentees = Number(newMenteesPrevPeriod[0]?.value ?? 0); const mentorChangePercent = prevMentors > 0 @@ -634,13 +643,15 @@ export class MentorshipService { ...matchCounts, total: totalMatches, declineRate, + acceptedThisPeriod: Number(acceptedMatchesThisPeriod[0]?.value ?? 0), }, growth: { - newMentorsLast30Days: newMentors, - newMenteesLast30Days: newMentees, + newMentors, + newMentees, mentorChangePercent, menteeChangePercent, dailyEnrollment, + days, }, }; } diff --git a/server/src/types/mentorship-types.ts b/server/src/types/mentorship-types.ts index 8cc4a58a..806e7c21 100644 --- a/server/src/types/mentorship-types.ts +++ b/server/src/types/mentorship-types.ts @@ -66,6 +66,11 @@ export type MatchedMentee = { matchedAt: string | Date; }; +export const getAdminStatsInputSchema = z.object({ + days: z.number().int().positive().default(30), +}); +export type GetAdminStatsInput = z.infer; + /** * Aggregated mentorship data returned to the frontend. * @@ -134,13 +139,15 @@ export const mentorshipAdminStatsOutputSchema = z.object({ declined: z.number(), total: z.number(), declineRate: z.number(), + acceptedThisPeriod: z.number(), }), growth: z.object({ - newMentorsLast30Days: z.number(), - newMenteesLast30Days: z.number(), - mentorChangePercent: z.number(), // compared to previous 30 days - menteeChangePercent: z.number(), // compared to previous 30 days + newMentors: z.number(), + newMentees: z.number(), + mentorChangePercent: z.number(), + menteeChangePercent: z.number(), dailyEnrollment: z.array(enrollmentDaySchema), + days: z.number(), }), }); diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx index 6683e13e..4e385a13 100644 --- a/web/src/app/admin/page.tsx +++ b/web/src/app/admin/page.tsx @@ -89,7 +89,7 @@ export default function AdminPage() { >
{/* Header Section */} -
+

@@ -137,8 +137,8 @@ export default function AdminPage() { {/* Empty State */} {adminFeatures.filter((f) => hasRole(roles, f.requiredRole)) .length === 0 && ( -
-

+

+

No admin features available for your permission level.

diff --git a/web/src/app/mentorship/admin/applications/page.tsx b/web/src/app/mentorship/admin/applications/page.tsx index e25ad31a..5e6f51d5 100644 --- a/web/src/app/mentorship/admin/applications/page.tsx +++ b/web/src/app/mentorship/admin/applications/page.tsx @@ -58,7 +58,7 @@ export default function MentorApplicationsPage() { trpc.mentorship.getPendingMentors.queryOptions({ status: "active" }), ); queryClient.invalidateQueries( - trpc.mentorship.getAdminStats.queryOptions(), + trpc.mentorship.getAdminStats.queryOptions({ days: 30 }), ); }, onError: () => setPendingId(null), diff --git a/web/src/app/mentorship/admin/page.tsx b/web/src/app/mentorship/admin/page.tsx index 4b967180..519d2e89 100644 --- a/web/src/app/mentorship/admin/page.tsx +++ b/web/src/app/mentorship/admin/page.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { + Download, FileText, Loader2, PenLine, @@ -11,6 +12,7 @@ import { } from "lucide-react"; import type { Route } from "next"; import Link from "next/link"; +import { useRef, useState } from "react"; import { CartesianGrid, Legend, @@ -28,6 +30,16 @@ import { useHasRole } from "@/hooks/useHasRole"; import { authClient } from "@/lib/auth-client"; import { useTRPC, useTRPCClient } from "@/lib/trpc"; +// make the rank introduction just the title, not the number ("Seargent" not "e5-seargent") +function parseRank(rank: string): string { + const parts = rank.split("-"); + if (parts.length < 2) return rank; + const words = parts + .slice(1) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)); + return words.join(" "); +} + // action buttons const actions: { label: string; @@ -35,7 +47,7 @@ const actions: { icon: React.ComponentType<{ className?: string }>; }[] = [ { - label: "View Pairs", + label: "View Matches", href: "/mentorship/admin/pairs" as Route, icon: FileText, }, @@ -51,14 +63,20 @@ const actions: { }, ]; +const DAY_OPTIONS = [30, 90, 180] as const; +type DayOption = (typeof DAY_OPTIONS)[number]; + // statistics card function StatCard({ label, value }: { label: string; value: number }) { return ( -
-

{label}

+
+

+ {label} +

{value}

+
); } @@ -68,26 +86,32 @@ function GrowthCard({ label, newCount, changePercent, + days, }: { label: string; newCount: number; changePercent: number; + days: number; }) { const isUp = changePercent >= 0; const TrendIcon = isUp ? TrendingUp : TrendingDown; const trendColor = isUp ? "text-green-600" : "text-red-500"; return ( -
-

{label}

+
+

+ {label} +

{newCount}

- {Math.abs(changePercent)}% over the last 30 days + + {Math.abs(changePercent)}% vs previous {days} days +
); @@ -98,6 +122,8 @@ export default function MentorshipAdminPage() { const trpc = useTRPC(); const trpcClient = useTRPCClient(); const isAdmin = useHasRole("global:admin"); + const [selectedDays, setSelectedDays] = useState(30); + const statsRef = useRef(null); const { data: sessionData } = authClient.useSession(); const userId = sessionData?.user.id ?? null; @@ -112,14 +138,71 @@ export default function MentorshipAdminPage() { }); const name = userData?.name ?? sessionData?.user.name ?? ""; - const rank = userData?.rank ?? ""; + const rawRank = userData?.rank ?? ""; + const rank = rawRank ? parseRank(rawRank) : ""; const displayName = [rank, name].filter(Boolean).join(" "); const { data, isLoading, error } = useQuery({ - ...trpc.mentorship.getAdminStats.queryOptions(), + ...trpc.mentorship.getAdminStats.queryOptions({ days: selectedDays }), enabled: isAdmin, + placeholderData: (prev) => prev, }); + const handleDownload = () => { + const date = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + const printWindow = window.open("", "_blank"); + if (!printWindow || !data || !statsRef.current) return; + + const chartHTML = + statsRef.current.querySelector(".recharts-responsive-container") + ?.outerHTML ?? ""; + + const content = ` + + + GuardConnect Mentorship Statistics — ${date} + + + +

GuardConnect Mentorship Statistics

+
Generated on ${date} — Last ${selectedDays} days
+

GuardConnect Mentorship Statistics

+

Total Mentorship Participants: ${data.mentors.total + data.mentees.total}

+

Mentorship Users Active Now: ${data.mentors.active + data.mentees.active}

+

Number of Mentees Waiting for Matching: ${data.mentees.active}

+

GuardConnect Mentorship Growth Statistics (Last ${selectedDays} Days)

+

New Mentors: ${data.growth.newMentors} (${ + data.growth.mentorChangePercent === 0 + ? `up 0% vs previous ${selectedDays} days` + : `${Math.abs(data.growth.mentorChangePercent)}% ${data.growth.mentorChangePercent > 0 ? "up" : "down"} vs previous ${selectedDays} days` + })

+

New Mentees: ${data.growth.newMentees} (${ + data.growth.menteeChangePercent === 0 + ? `up 0% vs previous ${selectedDays} days` + : `${Math.abs(data.growth.menteeChangePercent)}% ${data.growth.menteeChangePercent > 0 ? "up" : "down"} vs previous ${selectedDays} days` + })

+

Active Mentorship Pairs: ${data.matches.acceptedThisPeriod}

+

Program Enrollment (Last ${selectedDays} Days)

+ ${chartHTML} + + + `; + + printWindow.document.body.innerHTML = content; + printWindow.print(); + }; + if (!isAdmin) { return ( @@ -144,7 +227,7 @@ export default function MentorshipAdminPage() { @@ -158,7 +241,7 @@ export default function MentorshipAdminPage() { {/* Mentorship Actions */}

Mentorship Actions

-
+
{actions.map(({ label, href, icon: Icon }) => (
- {/* GuardConnect Statistics */} + {/* GuardConnect Mentorship Statistics */}

- GuardConnect Statistics + GuardConnect Mentorship Statistics

{isLoading && ( @@ -191,94 +274,164 @@ export default function MentorshipAdminPage() {

)} - {data && ( -
+
{/* Main stats */} -
+
-
- {/* Growth stats */} -
- - -
+ {/* Growth */} +
+ {/* Day filter and pdf download */} +
+

+ GuardConnect Mentorship Growth Statistics +

+
+
+ {DAY_OPTIONS.map((d) => ( + + ))} +
+ +
+
+ + {/* Growth cards */} +
+ + + +
- {/* Enrollment chart */} -
-

- Program Enrollment (Last 30 Days) -

- - - - { - const d = new Date(val); - return `${d.getMonth() + 1}/${d.getDate()}`; - }} - interval={4} - /> - - - new Date(val).toLocaleDateString() - } - formatter={(value, name) => [ - value, - name === "mentors" ? "Mentors" : "Mentees", - ]} - /> - - value === "mentors" ? "Mentors" : "Mentees" - } - /> - - - - + {/* Enrollment chart */} +
+

+ Program Enrollment (Last {selectedDays} Days) +

+ + + + { + const d = new Date(val); + return `${d.getMonth() + 1}/${d.getDate()}`; + }} + interval={Math.floor(selectedDays / 6)} + /> + + {/* Tooltip that removes space before rechart colon */} + { + if (!active || !payload?.length) return null; + return ( +
+

+ {label + ? new Date( + String(label), + ).toLocaleDateString() + : ""} +

+ {payload.map((entry) => ( +

+ {entry.dataKey === "mentors" + ? "Mentors" + : "Mentees"} + : {entry.value} +

+ ))} +
+ ); + }} + /> + + value === "mentors" ? "Mentors" : "Mentees" + } + /> + + +
+
+
)}