{hackathon.data.name}
+Check in to an event
++ Type the code shown on the screen or poster at the event. +
+diff --git a/sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx b/sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx
new file mode 100644
index 00000000..ea777b3d
--- /dev/null
+++ b/sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx
@@ -0,0 +1,594 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { loginHref } from "@/lib/safe-callback";
+import dynamic from "next/dynamic";
+import { useSession } from "next-auth/react";
+import { useTheme } from "next-themes";
+import { trpc } from "@/lib/trpc";
+import { useRouter } from "next/navigation";
+import {
+ btnSecondary,
+ itemTitle,
+ label,
+ meta,
+ body,
+ pageDek,
+ sectionTitle,
+} from "@/components/portal/ui";
+
+const Line = dynamic(() => import("react-chartjs-2").then((m) => m.Line), {
+ ssr: false,
+ loading: () =>
+ Membership, bootcamp enrolment, and turnout across every event and + hackathon. +
++ A bootcamp member counts from the month they joined the club, not + the month they bought the add-on. Only the term they bought is + recorded. +
++ Enrolment per term appears here once someone joins a bootcamp. +
+ ) : ( +| + Month + | ++ Joined + | ++ Members + | ++ Bootcamp members + | +
|---|---|---|---|
| + {monthLabel(row.month)} + | ++ {row.joined} + | ++ {row.members} + | ++ {row.bootcampMembers} + | +
+ Editions that are open, registration-closed, or in progress. These + are the same counts the metrics endpoint publishes. +
+ {live.isPending ? ( ++ No edition is open, registration-closed, or in progress. +
+ ) : ( + live.data.map((edition) => { + const judgingIdle = + edition.judgingActive && + edition.judgingProjects > 0 && + edition.votes === 0; + return ( +{phaseLabel(edition.phase)}
+ {judgingIdle && ( ++ Judging is on and no scores have landed. +
+ )} ++ Waitlisted {edition.participants.waitlisted} · Rejected{" "} + {edition.participants.rejected} +
+- Membership, bootcamp enrolment, and turnout across every event and - hackathon. -
-- A bootcamp member counts from the month they joined the club, not - the month they bought the add-on. Only the term they bought is - recorded. -
-- Enrolment per term appears here once someone joins a bootcamp. -
- ) : ( -| - Month - | -- Joined - | -- Members - | -- Bootcamp members - | -
|---|---|---|---|
| - {monthLabel(row.month)} - | -- {row.joined} - | -- {row.members} - | -- {row.bootcampMembers} - | -
- Editions that are open, registration-closed, or in progress. These - are the same counts the metrics endpoint publishes. -
- {live.isPending ? ( -- No edition is open, registration-closed, or in progress. -
- ) : ( - live.data.map((edition) => { - const judgingIdle = - edition.judgingActive && - edition.judgingProjects > 0 && - edition.votes === 0; - return ( -{phaseLabel(edition.phase)}
- {judgingIdle && ( -- Judging is on and no scores have landed. -
- )} -- Waitlisted {edition.participants.waitlisted} · Rejected{" "} - {edition.participants.rejected} -
-+ {proposal.proposerName ?? proposal.proposerEmail} ·{" "} + {proposal.proposerEmail} +
+ {proposal.summary && ( ++ {proposal.summary} +
+ )} + {proposal.description && ( ++ {proposal.description} +
+ )} ++ {proposal.commitment ?? "No commitment given"} ·{" "} + {proposal.maxMembers === null + ? "no team cap" + : `cap ${proposal.maxMembers}`} +
++ {review.error.message} +
+ )} ++ {leaders.error.message} +
++ A project leader can post projects and pick who joins them. It grants + nothing else; admin screens stay admin-only. +
+ ++ {proposals.error.message} +
+ ) : (proposals.data ?? []).length > 0 ? ( ++ Nothing waiting. Members pitch projects from their Projects page; + approving one makes them a project leader. +
+ )} ++ {setLeader.error.message} +
+ )} + + {rows.length > 0 ? ( ++ {leader.name ?? leader.email} + {!leader.isActive && ( + + revoked + + )} +
+{leader.email}
++ No leaders yet. Grant the role above and it takes effect on their + next request. +
+ )} +- {proposal.proposerName ?? proposal.proposerEmail} ·{" "} - {proposal.proposerEmail} -
- {proposal.summary && ( -- {proposal.summary} -
- )} - {proposal.description && ( -- {proposal.description} -
- )} -- {proposal.commitment ?? "No commitment given"} ·{" "} - {proposal.maxMembers === null - ? "no team cap" - : `cap ${proposal.maxMembers}`} -
-- {review.error.message} -
- )} -- {leaders.error.message} -
-- A project leader can post projects and pick who joins them. It grants - nothing else; admin screens stay admin-only. -
- -- {proposals.error.message} -
- ) : (proposals.data ?? []).length > 0 ? ( -- Nothing waiting. Members pitch projects from their Projects page; - approving one makes them a project leader. -
- )} -- {setLeader.error.message} -
- )} - - {rows.length > 0 ? ( -- {leader.name ?? leader.email} - {!leader.isActive && ( - - revoked - - )} -
-{leader.email}
-- No leaders yet. Grant the role above and it takes effect on their - next request. -
- )} -+ Browse the projects participants submitted, fix their details, or + withdraw one. +
+ ++ {projects?.length || 0} total projects +
++ Choose a hackathon above to see its submitted projects. +
+ ) : isLoading ? ( +Loading projects…
+ ) : !projects || projects.length === 0 ? ( ++ No projects yet. They will appear here once participants submit + them. +
+ ) : ( ++ {project.description || "No description"} +
+Tracks: {project.tracks.join(", ")}
+ )} +Team: {project.team?.name || "Unknown"}
++ {actionError} +
+ )} +- Browse the projects participants submitted, fix their details, or - withdraw one. -
- -- {projects?.length || 0} total projects -
-- Choose a hackathon above to see its submitted projects. -
- ) : isLoading ? ( -Loading projects…
- ) : !projects || projects.length === 0 ? ( -- No projects yet. They will appear here once participants submit - them. -
- ) : ( -- {project.description || "No description"} -
-Tracks: {project.tracks.join(", ")}
- )} -Team: {project.team?.name || "Unknown"}
-- {actionError} -
- )} -+ Every resume uploaded from a profile. Download a filtered set as one + ZIP, with an index.csv listing who is in it. +
+ ++ {scope === "members" + ? "Paid, unexpired memberships only." + : "Everyone with a resume on file, membership or not."} +
+Loading…
+ ) : rows.length === 0 ? ( ++ {debounced + ? "Nobody matches that search. Try a shorter name or another major." + : scope === "members" + ? "No current member has uploaded a resume yet. They will appear here once they add one from their profile." + : "No resumes uploaded yet. They will appear here once members add one from their profile."} +
+ ) : ( + <> +| + + | + {["Name", "Major", "Grad", "Status", "File", ""].map( + (heading, i) => ( ++ {heading} + | + ), + )} +|||||
|---|---|---|---|---|---|---|
| + toggle(row.userId)} + aria-label={`Include ${row.displayName}`} + className="accent-[var(--accent)] w-4 h-4" + /> + | +
+ + {row.displayName} + +{row.email} + |
+ + {row.major ?? "—"} + | ++ {row.graduationYear ?? "—"} + | ++ + {row.isCurrentMember ? "Member" : "Non-member"} + + | ++ + {formatSize(row.sizeBytes)} + {" "} + · {new Date(row.uploadedAt).toLocaleDateString()} + | ++ + | +
+ {offset + 1}–{offset + rows.length} of {total} +
++ {preview.name} +
++ {selected.size > 0 + ? `${selected.size} picked` + : "Nothing picked. The download takes everything that matches."} +
+- Every resume uploaded from a profile. Download a filtered set as one - ZIP, with an index.csv listing who is in it. -
- -- {scope === "members" - ? "Paid, unexpired memberships only." - : "Everyone with a resume on file, membership or not."} -
-Loading…
- ) : rows.length === 0 ? ( -- {debounced - ? "Nobody matches that search. Try a shorter name or another major." - : scope === "members" - ? "No current member has uploaded a resume yet. They will appear here once they add one from their profile." - : "No resumes uploaded yet. They will appear here once members add one from their profile."} -
- ) : ( - <> -| - - | - {["Name", "Major", "Grad", "Status", "File", ""].map( - (heading, i) => ( -- {heading} - | - ), - )} -|||||
|---|---|---|---|---|---|---|
| - toggle(row.userId)} - aria-label={`Include ${row.displayName}`} - className="accent-[var(--accent)] w-4 h-4" - /> - | -
- - {row.displayName} - -{row.email} - |
- - {row.major ?? "—"} - | -- {row.graduationYear ?? "—"} - | -- - {row.isCurrentMember ? "Member" : "Non-member"} - - | -- - {formatSize(row.sizeBytes)} - {" "} - · {new Date(row.uploadedAt).toLocaleDateString()} - | -- - | -
- {offset + 1}–{offset + rows.length} of {total} -
-- {preview.name} -
-- {selected.size > 0 - ? `${selected.size} picked` - : "Nothing picked. The download takes everything that matches."} -
-+ Only staff can grant roles. Ask an admin to add you or the person + you are trying to add. +
++ Grant the volunteer role before the event, not at the door. + {!isSuperAdmin && + " You can add and remove volunteers; other roles need a super admin."} +
+ ++ They must have signed in at least once. The role attaches to an + existing account. +
++ No account for {searchedEmail}. Ask them to sign in once, then look + again. +
+ )} + + {lookup.data && ( ++ {lookup.data.name ?? "Unnamed account"} +
+{lookup.data.email}
+ {lookup.data.existingRole && ( ++ Already {roleLabel(lookup.data.existingRole.role).toLowerCase()} + {lookup.data.existingRole.isActive ? "" : " (deactivated)"} + {lookup.data.existingRole.expiresAt + ? ` until ${termDate(lookup.data.existingRole.expiresAt)}` + : ""} +
+ )} ++ {roleOptions[0]?.hint} +
+ )} + + + {locked && ( ++ Only a super admin can change the{" "} + {roleLabel(heldRole ?? "").toLowerCase()} role. +
+ )} ++ {error} +
+ )} + {notice && ( ++ {notice} +
+ )} +Loading…
+ ) : (staff?.length ?? 0) === 0 ? ( ++ Nobody has a staff role yet. Find someone above to grant one. +
+ ) : ( ++ + {row.user?.name ?? "Unnamed account"} + + {!row.isActive && ( + + Deactivated + + )} +
+{row.user?.email}
++ {roleLabel(row.role)} + {row.expiresAt + // oxlint-disable-next-line react/purity + ? new Date(row.expiresAt) < new Date() + ? ` · expired ${termDate(row.expiresAt)}` + : ` · until ${termDate(row.expiresAt)}` + : ""} +
+- Only staff can grant roles. Ask an admin to add you or the person - you are trying to add. -
-- Grant the volunteer role before the event, not at the door. - {!isSuperAdmin && - " You can add and remove volunteers; other roles need a super admin."} -
- -- They must have signed in at least once. The role attaches to an - existing account. -
-- No account for {searchedEmail}. Ask them to sign in once, then look - again. -
- )} - - {lookup.data && ( -- {lookup.data.name ?? "Unnamed account"} -
-{lookup.data.email}
- {lookup.data.existingRole && ( -- Already {roleLabel(lookup.data.existingRole.role).toLowerCase()} - {lookup.data.existingRole.isActive ? "" : " (deactivated)"} - {lookup.data.existingRole.expiresAt - ? ` until ${termDate(lookup.data.existingRole.expiresAt)}` - : ""} -
- )} -- {roleOptions[0]?.hint} -
- )} - - - {locked && ( -- Only a super admin can change the{" "} - {roleLabel(heldRole ?? "").toLowerCase()} role. -
- )} -- {error} -
- )} - {notice && ( -- {notice} -
- )} -Loading…
- ) : (staff?.length ?? 0) === 0 ? ( -- Nobody has a staff role yet. Find someone above to grant one. -
- ) : ( -- - {row.user?.name ?? "Unnamed account"} - - {!row.isActive && ( - - Deactivated - - )} -
-{row.user?.email}
-- {roleLabel(row.role)} - {row.expiresAt - // oxlint-disable-next-line react/purity - ? new Date(row.expiresAt) < new Date() - ? ` · expired ${termDate(row.expiresAt)}` - : ` · until ${termDate(row.expiresAt)}` - : ""} -
-{subteam.summary}
+ )} ++ {subteam.pending} waiting · {subteam.accepted} on the team ·{" "} + {subteam.questions.length} question + {subteam.questions.length === 1 ? "" : "s"} +
++ {setArchived.error.message} +
+ )} ++ {applicant.name ?? applicant.email} +
++ + {applicant.email} + {" "} + · applied {shortDate(applicant.appliedAt)} +
+ + {applicant.answers.length > 0 && ( +{applicant.note}
+ )} + {applicant.decisionNote && applicant.status !== "pending" && ( +Note sent: {applicant.decisionNote}
+ )} + + {applicant.status === "pending" && ( ++ {decide.error.message} +
+ )} ++ {member.name ?? member.email} +
++ {member.email} + {member.decidedAt ? ` · since ${shortDate(member.decidedAt)}` : ""} +
+ {remove.error && ( ++ {remove.error.message} +
+ )} ++ Archived, so decisions are paused. Restore it from the list to + review again. +
+ )} ++ {roster.error.message} +
+ ) : members.length > 0 ? ( ++ {roster.isPending + ? "Loading…" + : "Nobody yet. Accepted applicants show up here."} +
+ )} ++ {applicants.error.message} +
+ ) : applicants.isPending ? ( +Loading…
+ ) : rows.length > 0 ? ( ++ {debouncedSearch.trim() || filter + ? "Nothing matches that filter." + : "Nobody has applied yet."} +
+ )} ++ {list.error.message} Refresh the page to try again. +
++ The groups members apply to join. Write the questions, open it, then + accept people onto the team. +
+ ++ No subteams yet. Create one and open it when its questions are + ready. +
+ )} +{subteam.summary}
- )} -- {subteam.pending} waiting · {subteam.accepted} on the team ·{" "} - {subteam.questions.length} question - {subteam.questions.length === 1 ? "" : "s"} -
-- {setArchived.error.message} -
- )} -- {applicant.name ?? applicant.email} -
-- - {applicant.email} - {" "} - · applied {shortDate(applicant.appliedAt)} -
- - {applicant.answers.length > 0 && ( -{applicant.note}
- )} - {applicant.decisionNote && applicant.status !== "pending" && ( -Note sent: {applicant.decisionNote}
- )} - - {applicant.status === "pending" && ( -- {decide.error.message} -
- )} -- {member.name ?? member.email} -
-- {member.email} - {member.decidedAt ? ` · since ${shortDate(member.decidedAt)}` : ""} -
- {remove.error && ( -- {remove.error.message} -
- )} -- Archived, so decisions are paused. Restore it from the list to - review again. -
- )} -- {roster.error.message} -
- ) : members.length > 0 ? ( -- {roster.isPending - ? "Loading…" - : "Nobody yet. Accepted applicants show up here."} -
- )} -- {applicants.error.message} -
- ) : applicants.isPending ? ( -Loading…
- ) : rows.length > 0 ? ( -- {debouncedSearch.trim() || filter - ? "Nothing matches that filter." - : "Nobody has applied yet."} -
- )} -- {list.error.message} Refresh the page to try again. -
-- The groups members apply to join. Write the questions, open it, then - accept people onto the team. -
- -- No subteams yet. Create one and open it when its questions are - ready. -
- )} -{hackathon.data.name}
++ Type the code shown on the screen or poster at the event. +
+{hackathon.data.name}
++ {checkedIn.pointsAwarded > 0 + ? `${pointsLabel(checkedIn.pointsAwarded)} added to your total.` + : "Your attendance is recorded. This event does not award points."} +
+ + See your points + +{hackathon.data.name}
++ {notArrived + ? "Event codes work once your pass has been scanned at check-in." + : trpcErrorMessage( + checkIn.error, + "The check-in did not go through. Type the code from the screen below.", + )} +
+ {offerForm && ( +{hackathon.data.name}
-- Type the code shown on the screen or poster at the event. -
-{hackathon.data.name}
-- {checkedIn.pointsAwarded > 0 - ? `${pointsLabel(checkedIn.pointsAwarded)} added to your total.` - : "Your attendance is recorded. This event does not award points."} -
- - See your points - -{hackathon.data.name}
-- {notArrived - ? "Event codes work once your pass has been scanned at check-in." - : trpcErrorMessage( - checkIn.error, - "The check-in did not go through. Type the code from the screen below.", - )} -
- {offerForm && ( -{edition?.name ?? "Hackathon staff"}
+Loading hackathons…
+ ) : ( ++ Choose a hackathon above to start scanning. +
+ )} ++ Who comes to club meetings, and how often. Scan member passes from + here too; hackathon check-in has its own desk. +
++ Choose a club event above to start scanning. +
+ )} ++ Attendance figures are for club officers. Scanning works the same + either way. +
+ )} +- Who comes to club meetings, and how often. Scan member passes from - here too; hackathon check-in has its own desk. -
-- Choose a club event above to start scanning. -
- )} -- Attendance figures are for club officers. Scanning works the same - either way. -
- )} -{edition?.name ?? "Hackathon staff"}
-Loading hackathons…
- ) : ( -- Choose a hackathon above to start scanning. -
- )} -