diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index f852716bd..0637da8a0 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -18,8 +18,8 @@ import {
Member,
} from "@forge/db/schemas/knight-hacks";
-import { permProcedure, publicProcedure } from "../trpc";
-import { calendar, controlPerms, discord, log } from "../utils";
+import { permProcedure, protectedProcedure, publicProcedure } from "../trpc";
+import { calendar, controlPerms, createForm, discord, log } from "../utils";
export const eventRouter = {
getEvents: publicProcedure.query(async () => {
@@ -515,4 +515,92 @@ export const eventRouter = {
// Step 3: Delete the event in the database
await db.delete(Event).where(eq(Event.id, input.id));
}),
+ ensureForm: protectedProcedure
+ .input(
+ z.object({
+ eventId: z.string().uuid(),
+ }),
+ )
+ .mutation(async ({ input }) =>{
+ const event = await db.query.Event.findFirst({
+ where: eq(Event.id, input.eventId),
+ });
+
+ if (!event)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Event with ID ${input.eventId} not found.`,
+ });
+
+ const formName = event.name + " Feedback Form";
+ const formSlugName = formName.toLowerCase().replaceAll(" ", "-");
+
+ const form = await db.query.FormsSchemas.findFirst({
+ where: (t, { eq }) => eq(t.slugName, formSlugName),
+ });
+
+ if (form === undefined) {
+ await createForm({
+ formData: {
+ name: formName,
+ description: `Provide feedback for ${event.name} to help us make events better in the future!`,
+ questions: [
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 0,
+ optional: false,
+ question: "How would you rate the event overall?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 1,
+ optional: false,
+ question: "How much fun did you have?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 2,
+ optional: false,
+ question: "How much did you learn?",
+ },
+ {
+ type: "MULTIPLE_CHOICE",
+ order: 3,
+ options: [
+ "Discord",
+ "Instagram",
+ "Knightconnect",
+ "Word of Mouth",
+ "CECS Emailing List",
+ "Reddit",
+ "LinkedIn",
+ "Class Presentation",
+ "Another Club",
+ ],
+ optional: false,
+ question: "Where did you hear about us?",
+ allowOther: true,
+ },
+ {
+ type: "SHORT_ANSWER",
+ order: 4,
+ optional: true,
+ question:
+ "Do you have any additional feedback about this event?",
+ },
+ ],
+ },
+ allowEdit: false,
+ allowResubmission: false,
+ duesOnly: false,
+ section: "Feedback",
+ });
+ }
+ }),
} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index acf56a202..812d58279 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -31,7 +31,7 @@ import {
import { minioClient } from "../minio/minio-client";
import { permProcedure, protectedProcedure, publicProcedure } from "../trpc";
-import { controlPerms, createForm, log } from "../utils";
+import { controlPerms, log } from "../utils";
export const memberRouter = {
createMember: protectedProcedure
@@ -666,78 +666,6 @@ export const memberRouter = {
code: "NOT_FOUND",
message: `Event with ID ${input.eventId} not found.`,
});
-
- const formName = event.name + " Feedback Form";
- const formSlugName = formName.toLowerCase().replaceAll(" ", "-");
-
- const form = await db.query.FormsSchemas.findFirst({
- where: (t, { eq }) => eq(t.slugName, formSlugName),
- });
-
- if (form === undefined) {
- await createForm({
- formData: {
- name: formName,
- description: `Provide feedback for ${event.name} to help us make events better in the future!`,
- questions: [
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 0,
- optional: false,
- question: "How would you rate the event overall?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 1,
- optional: false,
- question: "How much fun did you have?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 2,
- optional: false,
- question: "How much did you learn?",
- },
- {
- type: "MULTIPLE_CHOICE",
- order: 3,
- options: [
- "Discord",
- "Instagram",
- "Knightconnect",
- "Word of Mouth",
- "CECS Emailing List",
- "Reddit",
- "LinkedIn",
- "Class Presentation",
- "Another Club",
- ],
- optional: false,
- question: "Where did you hear about us?",
- allowOther: true,
- },
- {
- type: "SHORT_ANSWER",
- order: 4,
- optional: true,
- question:
- "Do you have any additional feedback about this event?",
- },
- ],
- },
- allowEdit: false,
- allowResubmission: false,
- duesOnly: false,
- section: "Feedback",
- });
- }
-
if (event.dues_paying) {
const duesPayingMember = await db.query.DuesPayment.findFirst({
where: eq(DuesPayment.memberId, member.id),
From 74bdb942c0a06c5f843e18ad25716eba3f535436 Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Fri, 13 Feb 2026 04:09:00 -0500
Subject: [PATCH 11/18] fix: lint + typecheck + format
---
.../_components/member-dashboard/member-dashboard.tsx | 7 +++----
packages/api/src/routers/event.ts | 10 +++++-----
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/apps/blade/src/app/dashboard/_components/member-dashboard/member-dashboard.tsx b/apps/blade/src/app/dashboard/_components/member-dashboard/member-dashboard.tsx
index 3ca067c82..c32c368cb 100644
--- a/apps/blade/src/app/dashboard/_components/member-dashboard/member-dashboard.tsx
+++ b/apps/blade/src/app/dashboard/_components/member-dashboard/member-dashboard.tsx
@@ -11,7 +11,6 @@ import { MemberInfo } from "./info";
import { Donate } from "./payment/donate";
import { Payment } from "./payment/payment-dues";
import { Points } from "./points";
-import { useEffect } from "react";
export const metadata: Metadata = {
title: "Member Dashboard",
@@ -102,9 +101,9 @@ export default async function MemberDashboard({
const isAlumni = calcAlumniStatus(member.gradDate, member);
- events.value.forEach(async (e) => {
- await api.event.ensureForm({ eventId: e.id });
- });
+ await Promise.all(
+ events.value.map((e) => api.event.ensureForm({ eventId: e.id })),
+ );
return (
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index 0637da8a0..e2b3e4620 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -515,13 +515,13 @@ export const eventRouter = {
// Step 3: Delete the event in the database
await db.delete(Event).where(eq(Event.id, input.id));
}),
- ensureForm: protectedProcedure
- .input(
+ ensureForm: protectedProcedure
+ .input(
z.object({
eventId: z.string().uuid(),
}),
- )
- .mutation(async ({ input }) =>{
+ )
+ .mutation(async ({ input }) => {
const event = await db.query.Event.findFirst({
where: eq(Event.id, input.eventId),
});
@@ -602,5 +602,5 @@ export const eventRouter = {
section: "Feedback",
});
}
- }),
+ }),
} satisfies TRPCRouterRecord;
From 3f3ed7e2cae3a4fc978211c73f1c2c2bcf565caf Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Fri, 13 Feb 2026 04:11:58 -0500
Subject: [PATCH 12/18] chore: remove unused procs
---
packages/api/src/routers/event-feedback.ts | 84 +---------------------
1 file changed, 2 insertions(+), 82 deletions(-)
diff --git a/packages/api/src/routers/event-feedback.ts b/packages/api/src/routers/event-feedback.ts
index 5fd4a4462..71e10a8fc 100644
--- a/packages/api/src/routers/event-feedback.ts
+++ b/packages/api/src/routers/event-feedback.ts
@@ -1,92 +1,12 @@
import type { TRPCRouterRecord } from "@trpc/server";
-import { TRPCError } from "@trpc/server";
import { z } from "zod";
-import { DISCORD, EVENTS } from "@forge/consts";
-import { and, eq, sql } from "@forge/db";
-import { db } from "@forge/db/client";
-import {
- EventFeedback,
- InsertEventFeedbackSchema,
- Member,
-} from "@forge/db/schemas/knight-hacks";
+import { DISCORD } from "@forge/consts";
import { permProcedure } from "../trpc";
-import { controlPerms, log } from "../utils";
+import { log } from "../utils";
export const eventFeedbackRouter = {
- createEventFeedback: permProcedure
- .input(InsertEventFeedbackSchema)
- .mutation(async ({ input, ctx }) => {
- controlPerms.or(["IS_JUDGE"], ctx);
- const existingFeedback = await db.query.EventFeedback.findFirst({
- where: (t, { eq }) =>
- and(eq(t.memberId, input.memberId), eq(t.eventId, input.eventId)),
- });
-
- if (existingFeedback) {
- throw new TRPCError({
- message: "Cannot give feedback more than once for this event!",
- code: "FORBIDDEN",
- });
- }
-
- const existingEvent = await db.query.Event.findFirst({
- where: (t, { eq }) => eq(t.id, input.eventId),
- });
-
- const existingMember = await db.query.Member.findFirst({
- where: (t, { eq }) => eq(t.id, input.memberId),
- });
-
- if (!existingEvent) {
- throw new TRPCError({
- message: "Event not found!",
- code: "NOT_FOUND",
- });
- }
-
- if (!existingMember) {
- throw new TRPCError({
- message: "Member not found!",
- code: "NOT_FOUND",
- });
- }
-
- await db.insert(EventFeedback).values({ ...input });
-
- await db
- .update(Member)
- .set({
- points: sql`${Member.points} + ${EVENTS.EVENT_FEEDBACK_POINTS_INCREMENT}`,
- })
- .where(eq(Member.id, input.memberId));
-
- await log({
- title: "Feedback Given",
- message: `${existingMember.firstName} ${existingMember.lastName} gave feedback for ${existingEvent.name}!`,
- color: "tk_blue",
- userId: ctx.session.user.discordUserId,
- });
- }),
-
- hasGivenFeedback: permProcedure
- .input(
- z.object({
- eventId: z.string(),
- memberId: z.string(),
- }),
- )
- .query(async ({ input, ctx }) => {
- controlPerms.or(["IS_JUDGE"], ctx);
- const givenFeedback = await db.query.EventFeedback.findFirst({
- where: (t, { eq }) =>
- and(eq(t.memberId, input.memberId), eq(t.eventId, input.eventId)),
- });
-
- return !!givenFeedback;
- }),
-
logHackathonFeedback: permProcedure
.input(
z.object({
From f5b07a0b906bf71088d1dd42f6e0ca34ea2b7f0a Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Fri, 13 Feb 2026 04:22:11 -0500
Subject: [PATCH 13/18] fix: more code rabbit stuff
---
packages/api/src/routers/event.ts | 129 +++++++++++++-----------
packages/db/src/schemas/knight-hacks.ts | 2 +-
2 files changed, 69 insertions(+), 62 deletions(-)
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index e2b3e4620..ac9ca850a 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -540,67 +540,74 @@ export const eventRouter = {
});
if (form === undefined) {
- await createForm({
- formData: {
- name: formName,
- description: `Provide feedback for ${event.name} to help us make events better in the future!`,
- questions: [
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 0,
- optional: false,
- question: "How would you rate the event overall?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 1,
- optional: false,
- question: "How much fun did you have?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 2,
- optional: false,
- question: "How much did you learn?",
- },
- {
- type: "MULTIPLE_CHOICE",
- order: 3,
- options: [
- "Discord",
- "Instagram",
- "Knightconnect",
- "Word of Mouth",
- "CECS Emailing List",
- "Reddit",
- "LinkedIn",
- "Class Presentation",
- "Another Club",
- ],
- optional: false,
- question: "Where did you hear about us?",
- allowOther: true,
- },
- {
- type: "SHORT_ANSWER",
- order: 4,
- optional: true,
- question:
- "Do you have any additional feedback about this event?",
- },
- ],
- },
- allowEdit: false,
- allowResubmission: false,
- duesOnly: false,
- section: "Feedback",
- });
+ try{
+ await createForm({
+ formData: {
+ name: formName,
+ description: `Provide feedback for ${event.name} to help us make events better in the future!`,
+ questions: [
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 0,
+ optional: false,
+ question: "How would you rate the event overall?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 1,
+ optional: false,
+ question: "How much fun did you have?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 2,
+ optional: false,
+ question: "How much did you learn?",
+ },
+ {
+ type: "MULTIPLE_CHOICE",
+ order: 3,
+ options: [
+ "Discord",
+ "Instagram",
+ "Knightconnect",
+ "Word of Mouth",
+ "CECS Emailing List",
+ "Reddit",
+ "LinkedIn",
+ "Class Presentation",
+ "Another Club",
+ ],
+ optional: false,
+ question: "Where did you hear about us?",
+ allowOther: true,
+ },
+ {
+ type: "SHORT_ANSWER",
+ order: 4,
+ optional: true,
+ question:
+ "Do you have any additional feedback about this event?",
+ },
+ ],
+ },
+ allowEdit: false,
+ allowResubmission: false,
+ duesOnly: false,
+ section: "Feedback",
+ });
+ } catch {
+ throw new TRPCError({
+ message: "Could not create form",
+ code: "INTERNAL_SERVER_ERROR"
+ });
+ }
}
}),
} satisfies TRPCRouterRecord;
diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts
index 8061c75db..cb99cf67d 100644
--- a/packages/db/src/schemas/knight-hacks.ts
+++ b/packages/db/src/schemas/knight-hacks.ts
@@ -484,7 +484,7 @@ export const InsertFormSectionSchema = createInsertSchema(FormSections);
export const FormsSchemas = createTable("form_schemas", (t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
name: t.varchar({ length: 255 }).notNull(),
- slugName: t.varchar({ length: 255 }).notNull(),
+ slugName: t.varchar({ length: 255 }).notNull().unique(),
createdAt: t.timestamp().notNull().defaultNow(),
duesOnly: t.boolean().notNull().default(false),
allowResubmission: t.boolean().notNull().default(false),
From d98fb307d4869d1679ff662dca2da43d2445437b Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Fri, 13 Feb 2026 04:22:28 -0500
Subject: [PATCH 14/18] fix: format
---
packages/api/src/routers/event.ts | 136 +++++++++++++++---------------
1 file changed, 68 insertions(+), 68 deletions(-)
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index ac9ca850a..c1d4710cd 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -540,74 +540,74 @@ export const eventRouter = {
});
if (form === undefined) {
- try{
- await createForm({
- formData: {
- name: formName,
- description: `Provide feedback for ${event.name} to help us make events better in the future!`,
- questions: [
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 0,
- optional: false,
- question: "How would you rate the event overall?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 1,
- optional: false,
- question: "How much fun did you have?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 2,
- optional: false,
- question: "How much did you learn?",
- },
- {
- type: "MULTIPLE_CHOICE",
- order: 3,
- options: [
- "Discord",
- "Instagram",
- "Knightconnect",
- "Word of Mouth",
- "CECS Emailing List",
- "Reddit",
- "LinkedIn",
- "Class Presentation",
- "Another Club",
- ],
- optional: false,
- question: "Where did you hear about us?",
- allowOther: true,
- },
- {
- type: "SHORT_ANSWER",
- order: 4,
- optional: true,
- question:
- "Do you have any additional feedback about this event?",
- },
- ],
- },
- allowEdit: false,
- allowResubmission: false,
- duesOnly: false,
- section: "Feedback",
- });
- } catch {
- throw new TRPCError({
- message: "Could not create form",
- code: "INTERNAL_SERVER_ERROR"
- });
- }
+ try {
+ await createForm({
+ formData: {
+ name: formName,
+ description: `Provide feedback for ${event.name} to help us make events better in the future!`,
+ questions: [
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 0,
+ optional: false,
+ question: "How would you rate the event overall?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 1,
+ optional: false,
+ question: "How much fun did you have?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 2,
+ optional: false,
+ question: "How much did you learn?",
+ },
+ {
+ type: "MULTIPLE_CHOICE",
+ order: 3,
+ options: [
+ "Discord",
+ "Instagram",
+ "Knightconnect",
+ "Word of Mouth",
+ "CECS Emailing List",
+ "Reddit",
+ "LinkedIn",
+ "Class Presentation",
+ "Another Club",
+ ],
+ optional: false,
+ question: "Where did you hear about us?",
+ allowOther: true,
+ },
+ {
+ type: "SHORT_ANSWER",
+ order: 4,
+ optional: true,
+ question:
+ "Do you have any additional feedback about this event?",
+ },
+ ],
+ },
+ allowEdit: false,
+ allowResubmission: false,
+ duesOnly: false,
+ section: "Feedback",
+ });
+ } catch {
+ throw new TRPCError({
+ message: "Could not create form",
+ code: "INTERNAL_SERVER_ERROR",
+ });
+ }
}
}),
} satisfies TRPCRouterRecord;
From ad3fc9e817713198a822e3cb612f008fcdc29b3d Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Fri, 13 Feb 2026 10:38:48 -0500
Subject: [PATCH 15/18] fix: use const for how heard
---
packages/api/src/routers/event.ts | 13 ++-----------
packages/api/src/routers/member.ts | 2 +-
2 files changed, 3 insertions(+), 12 deletions(-)
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index c1d4710cd..8c4b2bbe0 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -573,20 +573,11 @@ export const eventRouter = {
{
type: "MULTIPLE_CHOICE",
order: 3,
- options: [
- "Discord",
- "Instagram",
- "Knightconnect",
- "Word of Mouth",
- "CECS Emailing List",
- "Reddit",
- "LinkedIn",
- "Class Presentation",
- "Another Club",
- ],
+ options: [],
optional: false,
question: "Where did you hear about us?",
allowOther: true,
+ optionsConst: "EVENT_FEEDBACK_HEARD",
},
{
type: "SHORT_ANSWER",
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 812d58279..2cbd61a83 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -30,7 +30,7 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { minioClient } from "../minio/minio-client";
-import { permProcedure, protectedProcedure, publicProcedure } from "../trpc";
+import { permProcedure, protectedProcedure } from "../trpc";
import { controlPerms, log } from "../utils";
export const memberRouter = {
From e3d244cfa1b9dd3207f2beae43d2cc6a2d1778ab Mon Sep 17 00:00:00 2001
From: DGoel1602
Date: Sat, 14 Feb 2026 00:30:35 -0500
Subject: [PATCH 16/18] feat: add stats to event dashboard by rating and
feedback
---
.../data/_components/EventDemographics.tsx | 10 +
.../data/_components/event-data/HowFound.tsx | 198 ++++++++++++++++
.../_components/event-data/RatingRanking.tsx | 71 ++++++
packages/api/src/routers/event.ts | 222 +++++++++++++-----
packages/api/src/utils.ts | 34 ++-
packages/db/src/schemas/knight-hacks.ts | 1 +
6 files changed, 465 insertions(+), 71 deletions(-)
create mode 100644 apps/blade/src/app/admin/club/data/_components/event-data/HowFound.tsx
create mode 100644 apps/blade/src/app/admin/club/data/_components/event-data/RatingRanking.tsx
diff --git a/apps/blade/src/app/admin/club/data/_components/EventDemographics.tsx b/apps/blade/src/app/admin/club/data/_components/EventDemographics.tsx
index cbc9e7379..9c03b464f 100644
--- a/apps/blade/src/app/admin/club/data/_components/EventDemographics.tsx
+++ b/apps/blade/src/app/admin/club/data/_components/EventDemographics.tsx
@@ -15,7 +15,9 @@ import {
import { api } from "~/trpc/react";
import AttendancesBarChart from "./event-data/AttendancesBarChart";
import AttendancesMobile from "./event-data/AttendancesMobile";
+import FoundPie from "./event-data/HowFound";
import PopularityRanking from "./event-data/PopularityRanking";
+import RankingRating from "./event-data/RatingRanking";
import TypePie from "./event-data/TypePie";
import { WeekdayPopularityRadar } from "./event-data/WeekdayPopularityRadar";
@@ -107,6 +109,12 @@ export default function EventDemographics() {
})
.sort((a, b) => (a.tag > b.tag ? 1 : a.tag < b.tag ? -1 : 0)); // ensure same order of tags
+ const { data: feedback } = api.event.getFeedback.useQuery({
+ startDate: activeSemester?.startDate ?? null,
+ endDate: activeSemester?.endDate ?? null,
+ includeHackathons,
+ });
+
return (
{events && (
@@ -148,6 +156,7 @@ export default function EventDemographics() {
{filteredEvents && filteredEvents.length > 0 ? (
+ {feedback &&
}
{/* visible on large/medium screens */}
+
f.howHear)} />
) : (
diff --git a/apps/blade/src/app/admin/club/data/_components/event-data/HowFound.tsx b/apps/blade/src/app/admin/club/data/_components/event-data/HowFound.tsx
new file mode 100644
index 000000000..18a37d355
--- /dev/null
+++ b/apps/blade/src/app/admin/club/data/_components/event-data/HowFound.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import type { PieSectorDataItem } from "recharts/types/polar/Pie";
+import { useEffect, useMemo, useState } from "react";
+import { Cell, Label, Pie, PieChart, Sector } from "recharts";
+
+import type { ChartConfig } from "@forge/ui/chart";
+import { FORMS } from "@forge/consts";
+import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card";
+import {
+ ChartContainer,
+ ChartStyle,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@forge/ui/chart";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@forge/ui/select";
+
+export default function FoundPie({ found }: { found: string[] }) {
+ const id = "pie-interactive";
+
+ // get amount of each tag
+ const foundCount: Record
= {};
+ found.forEach((t) => {
+ foundCount[t] = (foundCount[t] ?? 0) + 1;
+ });
+
+ const totalEvents = found.length;
+
+ const foundData = Object.entries(foundCount).map(([t, count]) => ({
+ name: t,
+ amount: count,
+ percentage: (totalEvents > 0 ? (count / totalEvents) * 100 : 0).toFixed(2),
+ }));
+
+ const [activeLevel, setActiveLevel] = useState(
+ foundData[0] ? foundData[0].name : null,
+ );
+
+ const activeIndex = useMemo(
+ () => foundData.findIndex((item) => item.name === activeLevel),
+ [activeLevel, foundData],
+ );
+ const founds = useMemo(() => foundData.map((item) => item.name), [foundData]);
+
+ useEffect(() => {
+ if (!foundData.some((item) => item.name === activeLevel)) {
+ setActiveLevel(foundData[0]?.name ?? null);
+ }
+ }, [foundData, activeLevel]);
+
+ // set up chart config
+ const baseConfig: ChartConfig = {
+ events: { label: "events" },
+ };
+ let colorIdx = 0;
+ found.forEach((t) => {
+ if (!baseConfig[t]) {
+ baseConfig[t] = {
+ label: t,
+ color:
+ FORMS.ADMIN_PIE_CHART_COLORS[
+ colorIdx % FORMS.ADMIN_PIE_CHART_COLORS.length
+ ],
+ };
+ colorIdx++;
+ }
+ });
+
+ return (
+
+
+
+
+ Event Types
+
+
+
+
+
+
+ }
+ />
+ (
+
+
+
+
+ )}
+ >
+
+
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/club/data/_components/event-data/RatingRanking.tsx b/apps/blade/src/app/admin/club/data/_components/event-data/RatingRanking.tsx
new file mode 100644
index 000000000..c0f7067b4
--- /dev/null
+++ b/apps/blade/src/app/admin/club/data/_components/event-data/RatingRanking.tsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+
+import { FORMS } from "@forge/consts";
+import { Button } from "@forge/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card";
+
+interface Feedback {
+ event: string;
+ howHear: string;
+ rating: number;
+}
+
+export default function RatingRanking({ feedback }: { feedback: Feedback[] }) {
+ const [displayFullList, setDisplayFullList] = useState(false);
+
+ const aggregate = new Map();
+ feedback.forEach((f) => {
+ if (!aggregate.has(f.event)) aggregate.set(f.event, { total: 0, count: 0 });
+ const cur = aggregate.get(f.event);
+ if (cur)
+ aggregate.set(f.event, {
+ total: cur.total + f.rating,
+ count: cur.count + 1,
+ });
+ });
+ const averages = Array.from(aggregate.entries()).map(([k, v]) => {
+ return { name: k, average: v.total / v.count };
+ });
+
+ const topEvents = averages.sort((a, b) => b.average - a.average).slice(0, 10);
+
+ const handleClick = () => setDisplayFullList((prev) => !prev);
+
+ return (
+
+
+ Most Popular Events
+
+
+ {topEvents.length > 0 ? (
+
+ {(displayFullList ? topEvents : topEvents.slice(0, 3)).map(
+ (event, index: number) => (
+ -
+
+ {index + 1}. {event.name}
+
+ Average Rating: {event.average}
+
+ ),
+ )}
+
+ ) : (
+
+ No attendance data found
+
+ )}
+
+ {topEvents.length > 3 && ( // no need for show more toggle if there are 3 or less events
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index 8c4b2bbe0..c77668bd8 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -6,11 +6,23 @@ import { Routes } from "discord-api-types/v10";
import { z } from "zod";
import { DISCORD, EVENTS } from "@forge/consts";
-import { count, desc, eq, getTableColumns } from "@forge/db";
+import {
+ and,
+ count,
+ desc,
+ eq,
+ getTableColumns,
+ gt,
+ inArray,
+ isNull,
+ lt,
+} from "@forge/db";
import { db } from "@forge/db/client";
import {
Event,
EventAttendee,
+ FormResponse,
+ FormsSchemas,
Hacker,
HackerAttendee,
HackerEventAttendee,
@@ -515,13 +527,14 @@ export const eventRouter = {
// Step 3: Delete the event in the database
await db.delete(Event).where(eq(Event.id, input.id));
}),
+
ensureForm: protectedProcedure
.input(
z.object({
eventId: z.string().uuid(),
}),
)
- .mutation(async ({ input }) => {
+ .query(async ({ input }) => {
const event = await db.query.Event.findFirst({
where: eq(Event.id, input.eventId),
});
@@ -539,66 +552,151 @@ export const eventRouter = {
where: (t, { eq }) => eq(t.slugName, formSlugName),
});
- if (form === undefined) {
- try {
- await createForm({
- formData: {
- name: formName,
- description: `Provide feedback for ${event.name} to help us make events better in the future!`,
- questions: [
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 0,
- optional: false,
- question: "How would you rate the event overall?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 1,
- optional: false,
- question: "How much fun did you have?",
- },
- {
- max: 10,
- min: 0,
- type: "LINEAR_SCALE",
- order: 2,
- optional: false,
- question: "How much did you learn?",
- },
- {
- type: "MULTIPLE_CHOICE",
- order: 3,
- options: [],
- optional: false,
- question: "Where did you hear about us?",
- allowOther: true,
- optionsConst: "EVENT_FEEDBACK_HEARD",
- },
- {
- type: "SHORT_ANSWER",
- order: 4,
- optional: true,
- question:
- "Do you have any additional feedback about this event?",
- },
- ],
- },
- allowEdit: false,
- allowResubmission: false,
- duesOnly: false,
- section: "Feedback",
- });
- } catch {
- throw new TRPCError({
- message: "Could not create form",
- code: "INTERNAL_SERVER_ERROR",
- });
- }
+ if (form) return form;
+
+ try {
+ return await createForm({
+ formData: {
+ name: formName,
+ description: `Provide feedback for ${event.name} to help us make events better in the future!`,
+ questions: [
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 0,
+ optional: false,
+ question: "How would you rate the event overall?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 1,
+ optional: false,
+ question: "How much fun did you have?",
+ },
+ {
+ max: 10,
+ min: 0,
+ type: "LINEAR_SCALE",
+ order: 2,
+ optional: false,
+ question: "How much did you learn?",
+ },
+ {
+ type: "MULTIPLE_CHOICE",
+ order: 3,
+ options: [],
+ optional: false,
+ question: "Where did you hear about us?",
+ allowOther: true,
+ optionsConst: "EVENT_FEEDBACK_HEARD",
+ },
+ {
+ type: "SHORT_ANSWER",
+ order: 4,
+ optional: true,
+ question:
+ "Do you have any additional feedback about this event?",
+ },
+ ],
+ },
+ allowEdit: false,
+ allowResubmission: false,
+ duesOnly: false,
+ section: "Feedback",
+ });
+ } catch {
+ throw new TRPCError({
+ message: "Could not create form",
+ code: "INTERNAL_SERVER_ERROR",
+ });
}
}),
+
+ getFeedback: permProcedure
+ .input(
+ z.object({
+ startDate: z.date().nullable(),
+ endDate: z.date().nullable(),
+ includeHackathons: z.boolean(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ controlPerms.or(["READ_CLUB_EVENT"], ctx);
+
+ const conditions = [];
+
+ const isUsableDate = (d: Date | null): d is Date =>
+ d instanceof Date && Math.abs(d.getTime()) < 8640000000000000;
+
+ if (isUsableDate(input.startDate) && isUsableDate(input.endDate)) {
+ conditions.push(gt(Event.start_datetime, input.startDate));
+ conditions.push(lt(Event.start_datetime, input.endDate));
+ }
+
+ if (!input.includeHackathons) {
+ conditions.push(isNull(Event.hackathonId));
+ }
+
+ const events = await db
+ .select()
+ .from(Event)
+ .where(conditions.length ? and(...conditions) : undefined);
+
+ if (events.length === 0) {
+ return [];
+ }
+
+ const forms = await db
+ .select()
+ .from(FormsSchemas)
+ .where(
+ inArray(
+ FormsSchemas.slugName,
+ events.map((e) =>
+ (e.name + " Feedback Form").toLowerCase().replaceAll(" ", "-"),
+ ),
+ ),
+ );
+
+ if (forms.length === 0) {
+ return [];
+ }
+
+ const responses = await db
+ .select()
+ .from(FormResponse)
+ .where(
+ inArray(
+ FormResponse.form,
+ forms.map((f) => f.id),
+ ),
+ );
+
+ const feedback: { event: string; howHear: string; rating: number }[] = [];
+
+ const formIdToEvent = new Map();
+
+ for (const f of forms) {
+ const eventName = f.name.replace(" Feedback Form", "");
+ formIdToEvent.set(f.id, eventName);
+ }
+
+ for (const r of responses) {
+ const data = r.responseData as {
+ "Where did you hear about us?": string;
+ "How would you rate the event overall?": number;
+ };
+
+ feedback.push({
+ event: formIdToEvent.get(r.form) ?? "",
+ howHear: data["Where did you hear about us?"],
+ rating: data["How would you rate the event overall?"],
+ });
+ }
+
+ return feedback;
+ }),
} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts
index 014f66ad8..2c9863016 100644
--- a/packages/api/src/utils.ts
+++ b/packages/api/src/utils.ts
@@ -13,7 +13,11 @@ import type { Session } from "@forge/auth/server";
import { DISCORD, EVENTS, FORMS, MINIO, PERMISSIONS } from "@forge/consts";
import { db } from "@forge/db/client";
import { JudgeSession, Roles } from "@forge/db/schemas/auth";
-import { FormSchemaSchema, FormsSchemas } from "@forge/db/schemas/knight-hacks";
+import {
+ Form,
+ FormSchemaSchema,
+ FormsSchemas,
+} from "@forge/db/schemas/knight-hacks";
import { client } from "@forge/email";
import { env } from "./env";
@@ -508,7 +512,7 @@ export const CreateFormSchema = FormSchemaSchema.omit({
type CreateFormType = z.infer;
-export async function createForm(input: CreateFormType) {
+export async function createForm(input: CreateFormType): Promise