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
6 changes: 4 additions & 2 deletions server/src/routers/mentorship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {
getAdminMembersOutputSchema,
getAdminPairsOutputSchema,
getAdminStatsInputSchema,
getPendingMentorsInputSchema,
mentorshipAdminStatsOutputSchema,
mentorshipDataOutputSchema,
Expand Down Expand Up @@ -201,6 +202,7 @@ const updateMentorStatus = roleProcedure([GLOBAL_ADMIN_KEY])
);

const getAdminStats = roleProcedure([GLOBAL_ADMIN_KEY])
.input(getAdminStatsInputSchema)
.output(mentorshipAdminStatsOutputSchema)
.meta({
openapi: {
Expand All @@ -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);
}),
);

Expand Down
79 changes: 45 additions & 34 deletions server/src/service/mentorship-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,24 +473,25 @@ export class MentorshipService {
/**
* Get admin statistics for the mentorship program
*/
async getAdminStats(): Promise<MentorshipAdminStatsOutput> {
async getAdminStats(days = 30): Promise<MentorshipAdminStatsOutput> {
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(),
Expand All @@ -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<string>`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<string>`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 };
Expand All @@ -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] ?? "";
Expand All @@ -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
Expand Down Expand Up @@ -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,
},
};
}
Expand Down
15 changes: 11 additions & 4 deletions server/src/types/mentorship-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getAdminStatsInputSchema>;

/**
* Aggregated mentorship data returned to the frontend.
*
Expand Down Expand Up @@ -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(),
}),
});

Expand Down
6 changes: 3 additions & 3 deletions web/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default function AdminPage() {
>
<div className="w-full max-w-4xl mx-auto space-y-4 sm:space-y-6">
{/* Header Section */}
<div className="flex items-start gap-4 rounded-lg border border-primary bg-white p-4">
<div className="flex items-start gap-4 rounded-lg border border-primary bg-card p-4">
<ShieldCheck className="mt-0.5 h-6 w-6 flex-shrink-0 text-primary" />
<div className="flex-1">
<h2 className="text-lg font-semibold text-primary">
Expand Down Expand Up @@ -137,8 +137,8 @@ export default function AdminPage() {
{/* Empty State */}
{adminFeatures.filter((f) => hasRole(roles, f.requiredRole))
.length === 0 && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center">
<p className="text-sm text-gray-600">
<div className="rounded-lg border border-border bg-gray-50 p-8 text-center">
<p className="text-sm text-muted-foreground">
No admin features available for your permission level.
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/mentorship/admin/applications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading