From d0058b46edc44e5dedc5c767362f35ca12326c93 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 16:47:53 +0200 Subject: [PATCH] feat: Mentees & Activity tab on /admin/mentors Adds a project-centric ops view for mentor management: - New mentor.getMenteeActivity tRPC procedure aggregates every project with wantsMentorship=true and derives a status (unassigned / assigned / active / stalled) from the latest message + file activity. - /admin/mentors becomes a tabbed page: existing Mentor list + new Mentees & Activity table with status pills, search, and a per-row Assign/Open CTA linking to /admin/projects/[id]/mentor. - Includes 2 unit tests covering classification + program scoping. Also: ignore .remember/ (plugin scratch dir). --- .gitignore | 1 + src/app/(admin)/admin/mentors/page.tsx | 280 +++++++++++++++++++++++-- src/server/routers/mentor.ts | 119 +++++++++++ tests/unit/mentee-activity.test.ts | 167 +++++++++++++++ 4 files changed, 550 insertions(+), 17 deletions(-) create mode 100644 tests/unit/mentee-activity.test.ts diff --git a/.gitignore b/.gitignore index e135821..849fc51 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ build-output.txt # Private keys and secrets private/ public/build-id.json +.remember/ diff --git a/src/app/(admin)/admin/mentors/page.tsx b/src/app/(admin)/admin/mentors/page.tsx index 7f2e7ce..7eb88d7 100644 --- a/src/app/(admin)/admin/mentors/page.tsx +++ b/src/app/(admin)/admin/mentors/page.tsx @@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, TableBody, @@ -21,7 +22,8 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { ArrowUpDown, Search, Users } from 'lucide-react' +import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react' +import { formatEnumLabel } from '@/lib/utils' type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity' @@ -37,6 +39,16 @@ function formatRelativePast(date: Date | string | null): string { return `${Math.max(0, minutes)}m ago` } +const STATUS_BADGE: Record< + 'unassigned' | 'assigned' | 'active' | 'stalled', + { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' } +> = { + unassigned: { label: 'Unassigned', variant: 'outline' }, + assigned: { label: 'Assigned', variant: 'secondary' }, + active: { label: 'Active', variant: 'default' }, + stalled: { label: 'Stalled', variant: 'destructive' }, +} + type Mentor = { id: string name: string | null @@ -50,7 +62,7 @@ type Mentor = { lastActivityAt: Date | string | null } -export default function MentorsListPage() { +function MentorListPanel() { const [search, setSearch] = useState('') const [sortKey, setSortKey] = useState('load') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') @@ -128,21 +140,6 @@ export default function MentorsListPage() { return (
-
-
-

Mentors

-

- All users with the MENTOR role and their current workload. -

-
- -
-
@@ -279,3 +276,252 @@ export default function MentorsListPage() {
) } + +type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled' + +function MenteeActivityPanel() { + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + + const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({}) + + const filtered = useMemo(() => { + if (!data) return [] + const q = search.trim().toLowerCase() + return data.rows.filter((r) => { + if (statusFilter !== 'all' && r.status !== statusFilter) return false + if (!q) return true + const hay = [ + r.project.title, + r.project.country ?? '', + r.teamLead?.name ?? '', + r.teamLead?.email ?? '', + r.mentor?.name ?? '', + r.mentor?.email ?? '', + ] + .join(' ') + .toLowerCase() + return hay.includes(q) + }) + }, [data, search, statusFilter]) + + const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 } + + const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => ( + + ) + + return ( +
+
+ + + + Unassigned + + + +
{totals.unassigned}
+
+
+ + + + Assigned + + + +
{totals.assigned}
+
+
+ + + + Active + + + +
{totals.active}
+
+
+ + + + Stalled + + + +
+ {totals.stalled} +
+
+
+
+ + + +
+ Mentee teams +
+ + + + + +
+
+
+ + setSearch(e.target.value)} + placeholder="Search by project, team lead, or mentor…" + className="pl-9" + /> +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : filtered.length === 0 ? ( +
+ {search || statusFilter !== 'all' + ? 'No matching teams.' + : 'No teams have requested mentorship yet.'} +
+ ) : ( +
+ + + + Project + Status + Mentor + Messages + Files + Last activity + + + + + {filtered.map((r) => { + const badge = STATUS_BADGE[r.status] + return ( + + +
{r.project.title}
+
+ {r.teamLead?.name ?? r.teamLead?.email ?? '—'} + {r.project.oceanIssue && ( + <> + {' · '} + {formatEnumLabel(r.project.oceanIssue)} + + )} +
+
+ + + {badge.label} + + + + {r.mentor ? ( +
+
{r.mentor.name ?? r.mentor.email}
+
+ {r.mentor.currentLoad} + {r.mentor.maxAssignments != null + ? `/${r.mentor.maxAssignments}` + : ''} + {' load'} +
+
+ ) : ( + + )} +
+ + {r.messageCount} + + {r.fileCount} + + {formatRelativePast(r.lastActivityAt as unknown as Date | null)} + + + + +
+ ) + })} +
+
+
+ )} +
+
+
+ ) +} + +export default function MentorsListPage() { + return ( +
+
+
+

Mentors

+

+ Manage the mentor pool and track mentee teams across the program. +

+
+ +
+ + + + + Mentors + + + Mentees & Activity + + + + + + + + + +
+ ) +} diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index affeaec..22af955 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1072,6 +1072,125 @@ export const mentorRouter = router({ } }), + /** + * Project-centric activity view: every project that wants mentorship, + * with assignment status, latest activity timestamps, and a derived + * status (unassigned / assigned / active / stalled). + * Drives the "Mentees & Activity" tab on /admin/mentors. + */ + getMenteeActivity: adminProcedure + .input(z.object({ programId: z.string().optional() })) + .query(async ({ ctx, input }) => { + const projects = await ctx.prisma.project.findMany({ + where: { + wantsMentorship: true, + ...(input.programId ? { programId: input.programId } : {}), + }, + select: { + id: true, + title: true, + country: true, + status: true, + oceanIssue: true, + competitionCategory: true, + mentorAssignment: { + select: { + id: true, + method: true, + assignedAt: true, + completionStatus: true, + mentor: { + select: { + id: true, + name: true, + email: true, + maxAssignments: true, + _count: { select: { mentorAssignments: true } }, + }, + }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + files: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + _count: { select: { messages: true, files: true } }, + }, + }, + teamMembers: { + where: { role: 'LEAD' }, + take: 1, + select: { user: { select: { name: true, email: true } } }, + }, + }, + orderBy: { title: 'asc' }, + }) + + const ACTIVE_WINDOW_MS = 7 * 86_400_000 + const STALLED_WINDOW_MS = 14 * 86_400_000 + const now = Date.now() + + const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 } + + const rows = projects.map((p) => { + const ma = p.mentorAssignment + const lastMessageAt = ma?.messages[0]?.createdAt ?? null + const lastFileAt = ma?.files[0]?.createdAt ?? null + const lastActivityAt = [lastMessageAt, lastFileAt] + .filter((d): d is Date => d != null) + .sort((a, b) => b.getTime() - a.getTime())[0] ?? null + + let status: 'unassigned' | 'assigned' | 'active' | 'stalled' + if (!ma) { + status = 'unassigned' + } else if (lastActivityAt && now - lastActivityAt.getTime() <= ACTIVE_WINDOW_MS) { + status = 'active' + } else { + const referenceTime = lastActivityAt ?? ma.assignedAt + const elapsed = now - referenceTime.getTime() + status = elapsed > STALLED_WINDOW_MS ? 'stalled' : 'assigned' + } + totals[status]++ + + const teamLead = p.teamMembers[0]?.user ?? null + + return { + project: { + id: p.id, + title: p.title, + country: p.country, + status: p.status, + oceanIssue: p.oceanIssue, + competitionCategory: p.competitionCategory, + }, + teamLead: teamLead ? { name: teamLead.name, email: teamLead.email } : null, + mentor: ma?.mentor + ? { + id: ma.mentor.id, + name: ma.mentor.name, + email: ma.mentor.email, + currentLoad: ma.mentor._count.mentorAssignments, + maxAssignments: ma.mentor.maxAssignments, + } + : null, + assignmentMethod: ma?.method ?? null, + assignedAt: ma?.assignedAt ?? null, + lastMessageAt, + lastFileAt, + lastActivityAt, + messageCount: ma?._count.messages ?? 0, + fileCount: ma?._count.files ?? 0, + status, + } + }) + + return { rows, totals } + }), + /** * Get mentor's assigned projects */ diff --git a/tests/unit/mentee-activity.test.ts b/tests/unit/mentee-activity.test.ts new file mode 100644 index 0000000..980220b --- /dev/null +++ b/tests/unit/mentee-activity.test.ts @@ -0,0 +1,167 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' +import type { UserRole } from '@prisma/client' + +async function createUserWithRoles(primaryRole: UserRole, rolesArray: UserRole[]) { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: `Test ${primaryRole}`, + role: primaryRole, + roles: rolesArray, + status: 'ACTIVE', + }, + }) +} + +const DAY = 86_400_000 + +describe('mentor.getMenteeActivity', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('classifies projects as unassigned / assigned / active / stalled and returns totals', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `mentee-activity-${uid()}` }) + programIds.push(program.id) + + const lead = await createUserWithRoles('APPLICANT', ['APPLICANT']) + userIds.push(lead.id) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + userIds.push(mentor.id) + + // Four projects all wantsMentorship + const pUnassigned = await createTestProject(program.id, { title: 'Unassigned' }) + const pAssigned = await createTestProject(program.id, { title: 'Assigned' }) + const pActive = await createTestProject(program.id, { title: 'Active' }) + const pStalled = await createTestProject(program.id, { title: 'Stalled' }) + for (const p of [pUnassigned, pAssigned, pActive, pStalled]) { + await prisma.project.update({ where: { id: p.id }, data: { wantsMentorship: true } }) + await prisma.teamMember.create({ + data: { projectId: p.id, userId: lead.id, role: 'LEAD' }, + }) + } + + // Assigned: mentor assigned, no activity yet + await prisma.mentorAssignment.create({ + data: { + projectId: pAssigned.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + workspaceEnabled: true, + }, + }) + + // Active: mentor + recent message + const aActive = await prisma.mentorAssignment.create({ + data: { + projectId: pActive.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + workspaceEnabled: true, + }, + }) + await prisma.mentorMessage.create({ + data: { + projectId: pActive.id, + senderId: mentor.id, + message: 'recent ping', + workspaceId: aActive.id, + createdAt: new Date(Date.now() - 2 * DAY), + }, + }) + + // Stalled: mentor + last message > 14 days ago + const aStalled = await prisma.mentorAssignment.create({ + data: { + projectId: pStalled.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + workspaceEnabled: true, + assignedAt: new Date(Date.now() - 30 * DAY), + }, + }) + await prisma.mentorMessage.create({ + data: { + projectId: pStalled.id, + senderId: mentor.id, + message: 'old ping', + workspaceId: aStalled.id, + createdAt: new Date(Date.now() - 20 * DAY), + }, + }) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.getMenteeActivity({ programId: program.id }) + + const byTitle = Object.fromEntries( + result.rows.map((r: (typeof result.rows)[number]) => [r.project.title, r]), + ) + expect(byTitle['Unassigned'].status).toBe('unassigned') + expect(byTitle['Unassigned'].mentor).toBeNull() + expect(byTitle['Assigned'].status).toBe('assigned') + expect(byTitle['Assigned'].mentor?.id).toBe(mentor.id) + expect(byTitle['Active'].status).toBe('active') + expect(byTitle['Active'].lastActivityAt).not.toBeNull() + expect(byTitle['Stalled'].status).toBe('stalled') + + expect(result.totals.unassigned).toBe(1) + expect(result.totals.assigned).toBe(1) + expect(result.totals.active).toBe(1) + expect(result.totals.stalled).toBe(1) + + // Team lead resolved + expect(byTitle['Active'].teamLead?.email).toBe(lead.email) + }) + + it('only includes projects that wantMentorship within the program scope', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `mentee-scope-${uid()}` }) + programIds.push(program.id) + + const pYes = await createTestProject(program.id, { title: 'Yes' }) + const pNo = await createTestProject(program.id, { title: 'No' }) + await prisma.project.update({ where: { id: pYes.id }, data: { wantsMentorship: true } }) + await prisma.project.update({ where: { id: pNo.id }, data: { wantsMentorship: false } }) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.getMenteeActivity({ programId: program.id }) + + expect( + result.rows.map((r: (typeof result.rows)[number]) => r.project.title).sort(), + ).toEqual(['Yes']) + }) +})