From 5a1db8b065a0dd0b7e0b1c74702d33b627bd52d7 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Fri, 6 Nov 2026 20:30:53 -0500 Subject: [PATCH] perf(mainweb): prefetch remaining portal page reads on the server --- .../admin/analytics/AnalyticsPage.tsx | 594 +++++++++++++ .../app/(portal)/admin/analytics/page.tsx | 600 +------------ .../(portal)/admin/hackathons/[id]/page.tsx | 25 +- .../app/(portal)/admin/hackathons/page.tsx | 22 +- .../initiatives/AdminInitiativesPage.tsx | 302 +++++++ .../app/(portal)/admin/initiatives/page.tsx | 309 +------ .../(portal)/admin/projects/ProjectsPage.tsx | 312 +++++++ .../app/(portal)/admin/projects/page.tsx | 316 +------ .../admin/resumes/AdminResumesPage.tsx | 337 +++++++ .../app/(portal)/admin/resumes/page.tsx | 344 +------- .../app/(portal)/admin/staff/StaffPage.tsx | 352 ++++++++ .../mainweb/app/(portal)/admin/staff/page.tsx | 354 +------- .../admin/subteams/AdminSubteamsPage.tsx | 825 +++++++++++++++++ .../app/(portal)/admin/subteams/page.tsx | 827 +----------------- .../hackathons/[id]/checkin/CheckInPage.tsx | 212 +++++ .../(portal)/hackathons/[id]/checkin/page.tsx | 224 +---- .../app/(portal)/hackathons/[id]/page.tsx | 30 +- .../mainweb/app/(portal)/hacklytics/page.tsx | 22 +- .../app/(portal)/judge/[hackathonId]/page.tsx | 40 +- sites/mainweb/app/(portal)/scan/ScanPage.tsx | 89 ++ .../app/(portal)/scan/club/ClubScanPage.tsx | 135 +++ sites/mainweb/app/(portal)/scan/club/page.tsx | 147 +--- sites/mainweb/app/(portal)/scan/page.tsx | 110 +-- sites/mainweb/lib/next-step-reads.server.ts | 50 ++ 24 files changed, 3449 insertions(+), 3129 deletions(-) create mode 100644 sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx create mode 100644 sites/mainweb/app/(portal)/admin/initiatives/AdminInitiativesPage.tsx create mode 100644 sites/mainweb/app/(portal)/admin/projects/ProjectsPage.tsx create mode 100644 sites/mainweb/app/(portal)/admin/resumes/AdminResumesPage.tsx create mode 100644 sites/mainweb/app/(portal)/admin/staff/StaffPage.tsx create mode 100644 sites/mainweb/app/(portal)/admin/subteams/AdminSubteamsPage.tsx create mode 100644 sites/mainweb/app/(portal)/hackathons/[id]/checkin/CheckInPage.tsx create mode 100644 sites/mainweb/app/(portal)/scan/ScanPage.tsx create mode 100644 sites/mainweb/app/(portal)/scan/club/ClubScanPage.tsx create mode 100644 sites/mainweb/lib/next-step-reads.server.ts 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: () => , +}); +const Bar = dynamic(() => import("react-chartjs-2").then((m) => m.Bar), { + ssr: false, + loading: () => , +}); + +function ChartSkeleton() { + return ( +
+ ); +} + +/** Admin pages carry a smaller headline than member pages. */ +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)]"; + +const th = "px-4 py-2.5 text-[13px] font-medium text-[var(--text-subtle)]"; + +/** + * Canvas cannot read a CSS variable, so the portal tokens are resolved to + * literal colours here. Members draw in the accent, bootcamp in ink with a + * dash, so the two lines differ by more than hue. + */ +interface ChartTokens { + accent: string; + ink: string; + muted: string; + subtle: string; + grid: string; + card: string; + font: string; +} + +function readTokens(element: HTMLElement): ChartTokens { + const root = getComputedStyle(document.documentElement); + const read = (name: string) => root.getPropertyValue(name).trim(); + return { + accent: read("--accent"), + ink: read("--text-primary"), + muted: read("--text-muted"), + subtle: read("--text-subtle"), + grid: read("--border-subtle"), + card: read("--bg-card"), + font: getComputedStyle(element).fontFamily, + }; +} + +function phaseLabel(phase: string) { + if (phase === "open") return "Registration open"; + if (phase === "closed") return "Registration closed"; + if (phase === "in_progress") return "In progress"; + return phase; +} + +/** `2026-fall` is how it is stored; nobody should have to read it that way. */ +function termLabel(term: string) { + const [year, season] = term.split("-"); + if (!year || !season) return term; + return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; +} + +/** `2026-01` becomes `Jan 26`. Twelve of these have to fit one axis. */ +function monthLabel(month: string) { + const [year, index] = month.split("-"); + const date = new Date(Date.UTC(Number(year), Number(index) - 1, 1)); + const name = date.toLocaleString("en-US", { + month: "short", + timeZone: "UTC", + }); + return `${name} ${year?.slice(2)}`; +} + +const statCell = + "sm:border-l sm:border-[var(--border-subtle)] sm:pl-6 sm:first:border-l-0 sm:first:pl-0"; + +interface StatProps { + title: string; + value: string | number; + subtitle?: string; +} + +function Stat({ title, value, subtitle }: StatProps) { + return ( +
+
{title}
+
+ {value} +
+ {subtitle &&
{subtitle}
} +
+ ); +} + +function StatSkeleton() { + return ( +
+
+
+
+ ); +} + +/** A short line in the series colour, dashed for the dashed series. */ +function Swatch({ color, dashed = false }: { color: string; dashed?: boolean }) { + return ( + + ); +} + +export default function AnalyticsPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { resolvedTheme } = useTheme(); + const containerRef = useRef(null); + const [chartsReady, setChartsReady] = useState(false); + const [tokens, setTokens] = useState(null); + const [showTable, setShowTable] = useState(false); + + const { data: stats, isLoading } = trpc.admin.analyticsOverview.useQuery( + undefined, + // Matched to the server's cache entry. Polling faster only produced + // repeated cache hits and a request per tab per 5s for numbers that move + // on a much slower clock. + { enabled: !!session, refetchInterval: 15000 }, + ); + + const live = trpc.admin.hackathonLive.useQuery(undefined, { + enabled: !!session, + refetchInterval: 15000, + }); + + // Growth moves on a monthly clock, so it is fetched once rather than polled. + const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); + + useEffect(() => { + import("chart.js").then( + ({ + Chart, + LineElement, + PointElement, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + }) => { + Chart.register( + LineElement, + PointElement, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + ); + setChartsReady(true); + }, + ); + }, []); + + // next-themes swaps the class on in its own effect, which runs after + // this one, so the tokens are read a frame later when the new theme is live. + useEffect(() => { + const frame = requestAnimationFrame(() => { + if (containerRef.current) setTokens(readTokens(containerRef.current)); + }); + return () => cancelAnimationFrame(frame); + }, [resolvedTheme]); + + const options = useMemo(() => { + if (!tokens) return undefined; + const font = { family: tokens.font, size: 12 }; + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: { + // The legend is rendered as HTML above the chart, where it can carry a + // line style as well as a colour. + legend: { display: false }, + tooltip: { + backgroundColor: tokens.card, + borderColor: tokens.grid, + borderWidth: 1, + cornerRadius: 4, + titleColor: tokens.ink, + bodyColor: tokens.muted, + titleFont: { ...font, weight: 600 }, + bodyFont: font, + padding: 10, + }, + }, + scales: { + x: { + grid: { display: false }, + border: { color: tokens.grid }, + ticks: { color: tokens.subtle, font }, + }, + y: { + beginAtZero: true, + grid: { color: tokens.grid }, + border: { display: false }, + ticks: { color: tokens.subtle, font, precision: 0 }, + }, + }, + }; + }, [tokens]); + + if (status === "unauthenticated") { + router.push(loginHref()); + return null; + } + + const months = growth.data?.months ?? []; + const terms = growth.data?.terms ?? []; + const totals = growth.data?.totals; + const ready = chartsReady && !!tokens && !growth.isPending; + + const growthData = { + labels: months.map((row) => monthLabel(row.month)), + datasets: [ + { + label: "Members", + data: months.map((row) => row.members), + borderColor: tokens?.accent, + backgroundColor: tokens?.accent, + borderWidth: 2, + pointRadius: 3, + pointHoverRadius: 5, + pointStyle: "circle" as const, + tension: 0.25, + }, + { + label: "Bootcamp members", + data: months.map((row) => row.bootcampMembers), + borderColor: tokens?.ink, + backgroundColor: tokens?.ink, + borderWidth: 2, + // Dash and marker shape, not colour alone, separate the two lines. + borderDash: [6, 4], + pointRadius: 3, + pointHoverRadius: 5, + pointStyle: "rectRot" as const, + tension: 0.25, + }, + ], + }; + + const joinedData = { + labels: months.map((row) => monthLabel(row.month)), + datasets: [ + { + label: "Joined", + data: months.map((row) => row.joined), + backgroundColor: tokens?.accent, + borderRadius: 2, + maxBarThickness: 56, + borderSkipped: "bottom" as const, + }, + ], + }; + + const termData = { + labels: terms.map((row) => termLabel(row.term)), + datasets: [ + { + label: "Enrolled", + data: terms.map((row) => row.enrolled), + // Muted, not ink: a full-strength ink bar is a near-white slab in + // the night edition. + backgroundColor: tokens?.muted, + borderRadius: 2, + // One term would otherwise stretch to the whole chart width. + maxBarThickness: 56, + borderSkipped: "bottom" as const, + }, + ], + }; + + return ( +
+
+

Club growth

+

+ Membership, bootcamp enrolment, and turnout across every event and + hackathon. +

+
+ + {/* Membership */} +
+

Membership

+
+ {growth.isPending ? ( + [1, 2, 3, 4].map((i) => ) + ) : ( + <> + + + + + + )} +
+ + {/* Growth */} +
+
+

Members and bootcamp, running total

+ {/* Identity never rests on colour alone. */} +
    +
  • + + Members +
  • +
  • + + Bootcamp members +
  • +
+
+
+ {ready ? ( + + ) : ( + + )} +
+

+ 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. +

+
+ +
+
+

New members per month

+
+ {ready ? ( + + ) : ( + + )} +
+
+ +
+

Bootcamp enrolment per term

+
+ {!ready ? ( + + ) : terms.length === 0 ? ( +

+ Enrolment per term appears here once someone joins a bootcamp. +

+ ) : ( + + )} +
+
+
+ +
+ + + {showTable && ( +
+ + + + + + + + + + + + {months.map((row) => ( + + + + + + + ))} + +
+ The same twelve months as the charts above, as numbers. +
+ Month + + Joined + + Members + + Bootcamp members +
+ {monthLabel(row.month)} + + {row.joined} + + {row.members} + + {row.bootcampMembers} +
+
+ )} +
+
+ + {/* Events and hackathons */} +
+

Events and hackathons

+
+ {isLoading ? ( + [1, 2, 3, 4, 5].map((i) => ) + ) : ( + <> + + + + + + + )} +
+
+ +
+

Live hackathon

+

+ Editions that are open, registration-closed, or in progress. These + are the same counts the metrics endpoint publishes. +

+ {live.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : !live.data?.length ? ( +

+ No edition is open, registration-closed, or in progress. +

+ ) : ( + live.data.map((edition) => { + const judgingIdle = + edition.judgingActive && + edition.judgingProjects > 0 && + edition.votes === 0; + return ( +
+

{edition.edition}

+

{phaseLabel(edition.phase)}

+ {judgingIdle && ( +

+ Judging is on and no scores have landed. +

+ )} +
+ + + + + + + +
+

+ Waitlisted {edition.participants.waitlisted} · Rejected{" "} + {edition.participants.rejected} +

+
+ ); + }) + )} +
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index ea777b3d..df126184 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -1,594 +1,16 @@ -"use client"; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import AnalyticsPage from "./AnalyticsPage"; -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: () => , -}); -const Bar = dynamic(() => import("react-chartjs-2").then((m) => m.Bar), { - ssr: false, - loading: () => , -}); - -function ChartSkeleton() { - return ( -
- ); -} - -/** Admin pages carry a smaller headline than member pages. */ -const adminTitle = - "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)]"; - -const th = "px-4 py-2.5 text-[13px] font-medium text-[var(--text-subtle)]"; - -/** - * Canvas cannot read a CSS variable, so the portal tokens are resolved to - * literal colours here. Members draw in the accent, bootcamp in ink with a - * dash, so the two lines differ by more than hue. - */ -interface ChartTokens { - accent: string; - ink: string; - muted: string; - subtle: string; - grid: string; - card: string; - font: string; -} - -function readTokens(element: HTMLElement): ChartTokens { - const root = getComputedStyle(document.documentElement); - const read = (name: string) => root.getPropertyValue(name).trim(); - return { - accent: read("--accent"), - ink: read("--text-primary"), - muted: read("--text-muted"), - subtle: read("--text-subtle"), - grid: read("--border-subtle"), - card: read("--bg-card"), - font: getComputedStyle(element).fontFamily, - }; -} - -function phaseLabel(phase: string) { - if (phase === "open") return "Registration open"; - if (phase === "closed") return "Registration closed"; - if (phase === "in_progress") return "In progress"; - return phase; -} - -/** `2026-fall` is how it is stored; nobody should have to read it that way. */ -function termLabel(term: string) { - const [year, season] = term.split("-"); - if (!year || !season) return term; - return `${season.charAt(0).toUpperCase()}${season.slice(1)} ${year}`; -} - -/** `2026-01` becomes `Jan 26`. Twelve of these have to fit one axis. */ -function monthLabel(month: string) { - const [year, index] = month.split("-"); - const date = new Date(Date.UTC(Number(year), Number(index) - 1, 1)); - const name = date.toLocaleString("en-US", { - month: "short", - timeZone: "UTC", - }); - return `${name} ${year?.slice(2)}`; -} - -const statCell = - "sm:border-l sm:border-[var(--border-subtle)] sm:pl-6 sm:first:border-l-0 sm:first:pl-0"; - -interface StatProps { - title: string; - value: string | number; - subtitle?: string; -} - -function Stat({ title, value, subtitle }: StatProps) { - return ( -
-
{title}
-
- {value} -
- {subtitle &&
{subtitle}
} -
- ); -} - -function StatSkeleton() { - return ( -
-
-
-
- ); -} - -/** A short line in the series colour, dashed for the dashed series. */ -function Swatch({ color, dashed = false }: { color: string; dashed?: boolean }) { +export default function Page() { return ( - - ); -} - -export default function AnalyticsPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const { resolvedTheme } = useTheme(); - const containerRef = useRef(null); - const [chartsReady, setChartsReady] = useState(false); - const [tokens, setTokens] = useState(null); - const [showTable, setShowTable] = useState(false); - - const { data: stats, isLoading } = trpc.admin.analyticsOverview.useQuery( - undefined, - // Matched to the server's cache entry. Polling faster only produced - // repeated cache hits and a request per tab per 5s for numbers that move - // on a much slower clock. - { enabled: !!session, refetchInterval: 15000 }, - ); - - const live = trpc.admin.hackathonLive.useQuery(undefined, { - enabled: !!session, - refetchInterval: 15000, - }); - - // Growth moves on a monthly clock, so it is fetched once rather than polled. - const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); - - useEffect(() => { - import("chart.js").then( - ({ - Chart, - LineElement, - PointElement, - BarElement, - CategoryScale, - LinearScale, - Tooltip, - }) => { - Chart.register( - LineElement, - PointElement, - BarElement, - CategoryScale, - LinearScale, - Tooltip, - ); - setChartsReady(true); - }, - ); - }, []); - - // next-themes swaps the class on in its own effect, which runs after - // this one, so the tokens are read a frame later when the new theme is live. - useEffect(() => { - const frame = requestAnimationFrame(() => { - if (containerRef.current) setTokens(readTokens(containerRef.current)); - }); - return () => cancelAnimationFrame(frame); - }, [resolvedTheme]); - - const options = useMemo(() => { - if (!tokens) return undefined; - const font = { family: tokens.font, size: 12 }; - return { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index" as const, intersect: false }, - plugins: { - // The legend is rendered as HTML above the chart, where it can carry a - // line style as well as a colour. - legend: { display: false }, - tooltip: { - backgroundColor: tokens.card, - borderColor: tokens.grid, - borderWidth: 1, - cornerRadius: 4, - titleColor: tokens.ink, - bodyColor: tokens.muted, - titleFont: { ...font, weight: 600 }, - bodyFont: font, - padding: 10, - }, - }, - scales: { - x: { - grid: { display: false }, - border: { color: tokens.grid }, - ticks: { color: tokens.subtle, font }, - }, - y: { - beginAtZero: true, - grid: { color: tokens.grid }, - border: { display: false }, - ticks: { color: tokens.subtle, font, precision: 0 }, - }, - }, - }; - }, [tokens]); - - if (status === "unauthenticated") { - router.push(loginHref()); - return null; - } - - const months = growth.data?.months ?? []; - const terms = growth.data?.terms ?? []; - const totals = growth.data?.totals; - const ready = chartsReady && !!tokens && !growth.isPending; - - const growthData = { - labels: months.map((row) => monthLabel(row.month)), - datasets: [ - { - label: "Members", - data: months.map((row) => row.members), - borderColor: tokens?.accent, - backgroundColor: tokens?.accent, - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - pointStyle: "circle" as const, - tension: 0.25, - }, - { - label: "Bootcamp members", - data: months.map((row) => row.bootcampMembers), - borderColor: tokens?.ink, - backgroundColor: tokens?.ink, - borderWidth: 2, - // Dash and marker shape, not colour alone, separate the two lines. - borderDash: [6, 4], - pointRadius: 3, - pointHoverRadius: 5, - pointStyle: "rectRot" as const, - tension: 0.25, - }, - ], - }; - - const joinedData = { - labels: months.map((row) => monthLabel(row.month)), - datasets: [ - { - label: "Joined", - data: months.map((row) => row.joined), - backgroundColor: tokens?.accent, - borderRadius: 2, - maxBarThickness: 56, - borderSkipped: "bottom" as const, - }, - ], - }; - - const termData = { - labels: terms.map((row) => termLabel(row.term)), - datasets: [ - { - label: "Enrolled", - data: terms.map((row) => row.enrolled), - // Muted, not ink: a full-strength ink bar is a near-white slab in - // the night edition. - backgroundColor: tokens?.muted, - borderRadius: 2, - // One term would otherwise stretch to the whole chart width. - maxBarThickness: 56, - borderSkipped: "bottom" as const, - }, - ], - }; - - return ( -
[ + ["admin.analyticsOverview", undefined, c.admin.analyticsOverview()], + ["admin.hackathonLive", undefined, c.admin.hackathonLive()], + ["admin.growth", undefined, c.admin.growth()], + ]} > -
-

Club growth

-

- Membership, bootcamp enrolment, and turnout across every event and - hackathon. -

-
- - {/* Membership */} -
-

Membership

-
- {growth.isPending ? ( - [1, 2, 3, 4].map((i) => ) - ) : ( - <> - - - - - - )} -
- - {/* Growth */} -
-
-

Members and bootcamp, running total

- {/* Identity never rests on colour alone. */} -
    -
  • - - Members -
  • -
  • - - Bootcamp members -
  • -
-
-
- {ready ? ( - - ) : ( - - )} -
-

- 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. -

-
- -
-
-

New members per month

-
- {ready ? ( - - ) : ( - - )} -
-
- -
-

Bootcamp enrolment per term

-
- {!ready ? ( - - ) : terms.length === 0 ? ( -

- Enrolment per term appears here once someone joins a bootcamp. -

- ) : ( - - )} -
-
-
- -
- - - {showTable && ( -
- - - - - - - - - - - - {months.map((row) => ( - - - - - - - ))} - -
- The same twelve months as the charts above, as numbers. -
- Month - - Joined - - Members - - Bootcamp members -
- {monthLabel(row.month)} - - {row.joined} - - {row.members} - - {row.bootcampMembers} -
-
- )} -
-
- - {/* Events and hackathons */} -
-

Events and hackathons

-
- {isLoading ? ( - [1, 2, 3, 4, 5].map((i) => ) - ) : ( - <> - - - - - - - )} -
-
- -
-

Live hackathon

-

- Editions that are open, registration-closed, or in progress. These - are the same counts the metrics endpoint publishes. -

- {live.isPending ? ( -
- {[1, 2, 3, 4].map((i) => ( - - ))} -
- ) : !live.data?.length ? ( -

- No edition is open, registration-closed, or in progress. -

- ) : ( - live.data.map((edition) => { - const judgingIdle = - edition.judgingActive && - edition.judgingProjects > 0 && - edition.votes === 0; - return ( -
-

{edition.edition}

-

{phaseLabel(edition.phase)}

- {judgingIdle && ( -

- Judging is on and no scores have landed. -

- )} -
- - - - - - - -
-

- Waitlisted {edition.participants.waitlisted} · Rejected{" "} - {edition.participants.rejected} -

-
- ); - }) - )} -
-
+ + ); } diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index ec8ad6e8..e8e65285 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -1,5 +1,6 @@ import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import type { Prefetch } from "@/lib/portal-bootstrap.server"; import AdminHackathonDashboard from "./AdminHackathonPage"; // The Applications tab is what the dashboard opens on; a link to another tab @@ -20,7 +21,8 @@ export default async function Page({ const found = await hackathon.catch(() => null); if (!found) return [["hackathon.getById", { id }, hackathon]]; const hackathonId = found.id; - return [ + const { status } = found; + const entries: Prefetch[] = [ ["hackathon.getById", { id }, hackathon], ["hackathon.getById", { id: hackathonId }, hackathon], [ @@ -38,6 +40,27 @@ export default async function Page({ c.hackathon.analytics({ hackathonId }), ], ]; + // The Applications tab shows the interest list for an announced + // edition and the acceptance waves for any other. + if (status !== "announced") { + entries.push([ + "hackathon.waveStatus", + { hackathonId }, + c.hackathon.waveStatus({ hackathonId }), + ]); + } + if ( + status === "announced" || + status === "open" || + status === "in_progress" + ) { + entries.push([ + "hackathon.registrationOpenEmailStatus", + { hackathonId }, + c.hackathon.registrationOpenEmailStatus({ hackathonId }), + ]); + } + return entries; }} > diff --git a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx index 7c654d88..ed7ade84 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx @@ -1,13 +1,27 @@ import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import type { Prefetch } from "@/lib/portal-bootstrap.server"; import AdminHackathonsPage from "./AdminHackathonsPage"; export default function Page() { return ( [ - ["hackathon.listAll", undefined, c.hackathon.listAll()], - ["hackathon.listBans", undefined, c.hackathon.listBans()], - ]} + reads={async (c) => { + const all = c.hackathon.listAll(); + const entries: Prefetch[] = [ + ["hackathon.listAll", undefined, all], + ["hackathon.listBans", undefined, c.hackathon.listBans()], + ]; + // Each card reads its hackathon's events. + const hackathons = await all.catch(() => []); + for (const { id: hackathonId } of hackathons) { + entries.push([ + "hackathon.getEvents", + { hackathonId }, + c.hackathon.getEvents({ hackathonId }), + ]); + } + return entries; + }} > diff --git a/sites/mainweb/app/(portal)/admin/initiatives/AdminInitiativesPage.tsx b/sites/mainweb/app/(portal)/admin/initiatives/AdminInitiativesPage.tsx new file mode 100644 index 00000000..da974bf0 --- /dev/null +++ b/sites/mainweb/app/(portal)/admin/initiatives/AdminInitiativesPage.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { trpc } from "@/lib/trpc"; +import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context"; +import type { RouterOutputs } from "@query/api"; +import { + body, + btnDanger, + btnInk, + btnPrimary, + btnSecondary, + fieldLabel, + input, + itemTitle, + meta, + page, + pageDek, + sectionTitle, +} from "@/components/portal/ui"; + +const adminTitle = + "font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance"; + +/** + * Who runs club initiatives. + * + * Granting takes a user id rather than an email search: this reuses the + * attendees list every officer already works from, and a leader has to have + * signed in at least once to have an id at all. + */ +function ProposalRow({ + proposal, +}: { + proposal: RouterOutputs["initiative"]["listProposals"][number]; +}) { + const utils = trpc.useUtils(); + const readOnly = useReadOnly(); + const [note, setNote] = useState(""); + const [declining, setDeclining] = useState(false); + + const review = trpc.initiative.reviewProposal.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.initiative.listProposals.invalidate(), + // Approving mints a project leader, so that list moves too. + utils.initiative.listLeaders.invalidate(), + ]); + }, + }); + + return ( +
+
+
+

{proposal.title}

+

+ {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}`} +

+
+ +
+ + +
+
+ + {/* A decline without a reason is the thing a member can do nothing with, + so the note is asked for at the moment of declining. */} + {declining && ( +
+ +