diff --git a/apps/api/docs/docs.go b/apps/api/docs/docs.go index 6b9e9806..1b4d3dd8 100644 --- a/apps/api/docs/docs.go +++ b/apps/api/docs/docs.go @@ -156,21 +156,25 @@ const docTemplate = `{ ], "type": "object" }, - "handlers.QueueEmailRequest": { + "handlers.QueueTextEmailRequest": { "properties": { "body": { "type": "string" }, - "from": { + "subject": { "type": "string" }, "to": { - "type": "string" + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false } }, "required": [ "body", - "from", + "subject", "to" ], "type": "object" @@ -272,6 +276,49 @@ const docTemplate = `{ ], "type": "object" }, + "services.ApplicationStatistics": { + "properties": { + "age_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationAgeSplitRow" + }, + "gender_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationGenderSplitRow" + }, + "major_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationMajorSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "race_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationRaceSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "school_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationSchoolSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "status_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationStatusSplitRow" + } + }, + "required": [ + "age_stats", + "gender_stats", + "major_stats", + "race_stats", + "school_stats", + "status_stats" + ], + "type": "object" + }, "sqlc.Application": { "properties": { "application": { @@ -293,6 +340,9 @@ const docTemplate = `{ "status": { "$ref": "#/components/schemas/sqlc.NullApplicationStatus" }, + "submitted_at": { + "type": "string" + }, "updated_at": { "type": "string" }, @@ -306,6 +356,7 @@ const docTemplate = `{ "event_id", "saved_at", "status", + "submitted_at", "updated_at", "user_id" ], @@ -485,6 +536,144 @@ const docTemplate = `{ "EventRoleTypeApplicant" ] }, + "sqlc.GetApplicationAgeSplitRow": { + "properties": { + "age_18": { + "type": "integer" + }, + "age_19": { + "type": "integer" + }, + "age_20": { + "type": "integer" + }, + "age_21": { + "type": "integer" + }, + "age_22": { + "type": "integer" + }, + "age_23_plus": { + "type": "integer" + }, + "underage": { + "type": "integer" + } + }, + "required": [ + "age_18", + "age_19", + "age_20", + "age_21", + "age_22", + "age_23_plus", + "underage" + ], + "type": "object" + }, + "sqlc.GetApplicationGenderSplitRow": { + "properties": { + "female": { + "type": "integer" + }, + "male": { + "type": "integer" + }, + "non_binary": { + "type": "integer" + }, + "other": { + "type": "integer" + } + }, + "required": [ + "female", + "male", + "non_binary", + "other" + ], + "type": "object" + }, + "sqlc.GetApplicationMajorSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "major": { + "type": "string" + } + }, + "required": [ + "count", + "major" + ], + "type": "object" + }, + "sqlc.GetApplicationRaceSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "race_group": { + "type": "string" + } + }, + "required": [ + "count", + "race_group" + ], + "type": "object" + }, + "sqlc.GetApplicationSchoolSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "school": { + "type": "string" + } + }, + "required": [ + "count", + "school" + ], + "type": "object" + }, + "sqlc.GetApplicationStatusSplitRow": { + "properties": { + "accepted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "started": { + "type": "integer" + }, + "submitted": { + "type": "integer" + }, + "under_review": { + "type": "integer" + }, + "waitlisted": { + "type": "integer" + }, + "withdrawn": { + "type": "integer" + } + }, + "required": [ + "accepted", + "rejected", + "started", + "submitted", + "under_review", + "waitlisted", + "withdrawn" + ], + "type": "object" + }, "sqlc.GetEventStaffRow": { "properties": { "created_at": { @@ -859,7 +1048,7 @@ const docTemplate = `{ "type": "object" }, { - "$ref": "#/components/schemas/handlers.QueueEmailRequest", + "$ref": "#/components/schemas/handlers.QueueTextEmailRequest", "summary": "request", "description": "Email data" } @@ -1297,6 +1486,47 @@ const docTemplate = `{ ] } }, + "/events/{eventId}/application/stats": { + "get": { + "description": "This aggregates applications by race, gender, age, majors, and schools. This route is only available to event staff and admins.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/services.ApplicationStatistics" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad request/Malformed request." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: error getting statistics" + } + }, + "summary": "Gets an event's submitted application statistics", + "tags": [ + "Application" + ] + } + }, "/events/{eventId}/application/submit": { "post": { "description": "Submit the application for an event.", diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index d4a758aa..01c385e7 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -149,21 +149,25 @@ ], "type": "object" }, - "handlers.QueueEmailRequest": { + "handlers.QueueTextEmailRequest": { "properties": { "body": { "type": "string" }, - "from": { + "subject": { "type": "string" }, "to": { - "type": "string" + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false } }, "required": [ "body", - "from", + "subject", "to" ], "type": "object" @@ -265,6 +269,49 @@ ], "type": "object" }, + "services.ApplicationStatistics": { + "properties": { + "age_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationAgeSplitRow" + }, + "gender_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationGenderSplitRow" + }, + "major_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationMajorSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "race_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationRaceSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "school_stats": { + "items": { + "$ref": "#/components/schemas/sqlc.GetApplicationSchoolSplitRow" + }, + "type": "array", + "uniqueItems": false + }, + "status_stats": { + "$ref": "#/components/schemas/sqlc.GetApplicationStatusSplitRow" + } + }, + "required": [ + "age_stats", + "gender_stats", + "major_stats", + "race_stats", + "school_stats", + "status_stats" + ], + "type": "object" + }, "sqlc.Application": { "properties": { "application": { @@ -286,6 +333,9 @@ "status": { "$ref": "#/components/schemas/sqlc.NullApplicationStatus" }, + "submitted_at": { + "type": "string" + }, "updated_at": { "type": "string" }, @@ -299,6 +349,7 @@ "event_id", "saved_at", "status", + "submitted_at", "updated_at", "user_id" ], @@ -478,6 +529,144 @@ "EventRoleTypeApplicant" ] }, + "sqlc.GetApplicationAgeSplitRow": { + "properties": { + "age_18": { + "type": "integer" + }, + "age_19": { + "type": "integer" + }, + "age_20": { + "type": "integer" + }, + "age_21": { + "type": "integer" + }, + "age_22": { + "type": "integer" + }, + "age_23_plus": { + "type": "integer" + }, + "underage": { + "type": "integer" + } + }, + "required": [ + "age_18", + "age_19", + "age_20", + "age_21", + "age_22", + "age_23_plus", + "underage" + ], + "type": "object" + }, + "sqlc.GetApplicationGenderSplitRow": { + "properties": { + "female": { + "type": "integer" + }, + "male": { + "type": "integer" + }, + "non_binary": { + "type": "integer" + }, + "other": { + "type": "integer" + } + }, + "required": [ + "female", + "male", + "non_binary", + "other" + ], + "type": "object" + }, + "sqlc.GetApplicationMajorSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "major": { + "type": "string" + } + }, + "required": [ + "count", + "major" + ], + "type": "object" + }, + "sqlc.GetApplicationRaceSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "race_group": { + "type": "string" + } + }, + "required": [ + "count", + "race_group" + ], + "type": "object" + }, + "sqlc.GetApplicationSchoolSplitRow": { + "properties": { + "count": { + "type": "integer" + }, + "school": { + "type": "string" + } + }, + "required": [ + "count", + "school" + ], + "type": "object" + }, + "sqlc.GetApplicationStatusSplitRow": { + "properties": { + "accepted": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "started": { + "type": "integer" + }, + "submitted": { + "type": "integer" + }, + "under_review": { + "type": "integer" + }, + "waitlisted": { + "type": "integer" + }, + "withdrawn": { + "type": "integer" + } + }, + "required": [ + "accepted", + "rejected", + "started", + "submitted", + "under_review", + "waitlisted", + "withdrawn" + ], + "type": "object" + }, "sqlc.GetEventStaffRow": { "properties": { "created_at": { @@ -852,7 +1041,7 @@ "type": "object" }, { - "$ref": "#/components/schemas/handlers.QueueEmailRequest", + "$ref": "#/components/schemas/handlers.QueueTextEmailRequest", "summary": "request", "description": "Email data" } @@ -1290,6 +1479,47 @@ ] } }, + "/events/{eventId}/application/stats": { + "get": { + "description": "This aggregates applications by race, gender, age, majors, and schools. This route is only available to event staff and admins.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/services.ApplicationStatistics" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Bad request/Malformed request." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: error getting statistics" + } + }, + "summary": "Gets an event's submitted application statistics", + "tags": [ + "Application" + ] + } + }, "/events/{eventId}/application/submit": { "post": { "description": "Submit the application for an event.", diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index a0d8f83a..02590918 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -104,17 +104,20 @@ components: - role - user_id type: object - handlers.QueueEmailRequest: + handlers.QueueTextEmailRequest: properties: body: type: string - from: + subject: type: string to: - type: string + items: + type: string + type: array + uniqueItems: false required: - body - - from + - subject - to type: object handlers.UpdateEmailConsentRequest: @@ -189,6 +192,37 @@ components: - error - message type: object + services.ApplicationStatistics: + properties: + age_stats: + $ref: '#/components/schemas/sqlc.GetApplicationAgeSplitRow' + gender_stats: + $ref: '#/components/schemas/sqlc.GetApplicationGenderSplitRow' + major_stats: + items: + $ref: '#/components/schemas/sqlc.GetApplicationMajorSplitRow' + type: array + uniqueItems: false + race_stats: + items: + $ref: '#/components/schemas/sqlc.GetApplicationRaceSplitRow' + type: array + uniqueItems: false + school_stats: + items: + $ref: '#/components/schemas/sqlc.GetApplicationSchoolSplitRow' + type: array + uniqueItems: false + status_stats: + $ref: '#/components/schemas/sqlc.GetApplicationStatusSplitRow' + required: + - age_stats + - gender_stats + - major_stats + - race_stats + - school_stats + - status_stats + type: object sqlc.Application: properties: application: @@ -204,6 +238,8 @@ components: type: string status: $ref: '#/components/schemas/sqlc.NullApplicationStatus' + submitted_at: + type: string updated_at: type: string user_id: @@ -214,6 +250,7 @@ components: - event_id - saved_at - status + - submitted_at - updated_at - user_id type: object @@ -348,6 +385,102 @@ components: - EventRoleTypeStaff - EventRoleTypeAttendee - EventRoleTypeApplicant + sqlc.GetApplicationAgeSplitRow: + properties: + age_18: + type: integer + age_19: + type: integer + age_20: + type: integer + age_21: + type: integer + age_22: + type: integer + age_23_plus: + type: integer + underage: + type: integer + required: + - age_18 + - age_19 + - age_20 + - age_21 + - age_22 + - age_23_plus + - underage + type: object + sqlc.GetApplicationGenderSplitRow: + properties: + female: + type: integer + male: + type: integer + non_binary: + type: integer + other: + type: integer + required: + - female + - male + - non_binary + - other + type: object + sqlc.GetApplicationMajorSplitRow: + properties: + count: + type: integer + major: + type: string + required: + - count + - major + type: object + sqlc.GetApplicationRaceSplitRow: + properties: + count: + type: integer + race_group: + type: string + required: + - count + - race_group + type: object + sqlc.GetApplicationSchoolSplitRow: + properties: + count: + type: integer + school: + type: string + required: + - count + - school + type: object + sqlc.GetApplicationStatusSplitRow: + properties: + accepted: + type: integer + rejected: + type: integer + started: + type: integer + submitted: + type: integer + under_review: + type: integer + waitlisted: + type: integer + withdrawn: + type: integer + required: + - accepted + - rejected + - started + - submitted + - under_review + - waitlisted + - withdrawn + type: object sqlc.GetEventStaffRow: properties: created_at: @@ -608,7 +741,7 @@ paths: schema: oneOf: - type: object - - $ref: '#/components/schemas/handlers.QueueEmailRequest' + - $ref: '#/components/schemas/handlers.QueueTextEmailRequest' description: Email data summary: request description: Email data @@ -875,6 +1008,32 @@ paths: summary: Save Application tags: - Application + /events/{eventId}/application/stats: + get: + description: This aggregates applications by race, gender, age, majors, and + schools. This route is only available to event staff and admins. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/services.ApplicationStatistics' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Bad request/Malformed request. + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Server Error: error getting statistics' + summary: Gets an event's submitted application statistics + tags: + - Application /events/{eventId}/application/submit: post: description: Submit the application for an event. diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index ee89b442..83832119 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -43,6 +43,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Both requireXXRole functions automatically allow superusers ensureSuperuser = mw.Auth.RequirePlatformRole([]sqlc.AuthUserRole{sqlc.AuthUserRoleSuperuser}) ensureEventAdmin = mw.Event.RequireEventRole([]sqlc.EventRoleType{sqlc.EventRoleTypeAdmin}) + // Event Admins are technically Staff... + ensureEventStaff = mw.Event.RequireEventRole([]sqlc.EventRoleType{sqlc.EventRoleTypeAdmin, sqlc.EventRoleTypeStaff}) ) AllowedOrigins := config.Load().AllowedOrigins @@ -121,7 +123,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Event-specific routes r.Route("/{eventId}", func(r chi.Router) { r.Use(mw.Auth.RequireAuth) // routes below this are protected - + r.Get("/", api.Handlers.Event.GetEventByID) r.Get("/role", api.Handlers.Event.GetEventRole) @@ -143,6 +145,9 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Get("/", api.Handlers.Application.GetApplicationByUserAndEventID) r.Post("/submit", api.Handlers.Application.SubmitApplication) r.Post("/save", api.Handlers.Application.SaveApplication) + + // For statistics (Staff ONLY) + r.With(ensureEventStaff).Get("/stats", api.Handlers.Application.GetApplicationStatistics) }) }) }) diff --git a/apps/api/internal/api/handlers/application.go b/apps/api/internal/api/handlers/application.go index af82e159..335a0603 100644 --- a/apps/api/internal/api/handlers/application.go +++ b/apps/api/internal/api/handlers/application.go @@ -145,6 +145,7 @@ func (h *ApplicationHandler) SubmitApplication(w http.ResponseWriter, r *http.Re submission.PreferredEmail = r.FormValue("preferredEmail") submission.UniversityEmail = r.FormValue("universityEmail") + submission.Country = r.FormValue("country") submission.Gender = r.FormValue("gender") submission.GenderOther = r.FormValue("gender-other") submission.Pronouns = r.FormValue("pronouns") @@ -285,3 +286,35 @@ func (h *ApplicationHandler) SaveApplication(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusOK) } + +// Get Application Statistics +// +// @Summary Gets an event's submitted application statistics +// @Description This aggregates applications by race, gender, age, majors, and schools. This route is only available to event staff and admins. +// @Tags Application +// @Produce json +// @Success 200 {object} services.ApplicationStatistics +// @Failure 400 {object} response.ErrorResponse "Bad request/Malformed request." +// @Failure 500 {object} response.ErrorResponse "Server Error: error getting statistics" +// @Router /events/{eventId}/application/stats [get] +func (h *ApplicationHandler) GetApplicationStatistics(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID.")) + return + } + + appStats, err := h.appService.GetApplicationStatistics(r.Context(), eventId) + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("application_stats_err", "Something went wrong while aggregating application statistics.")) + return + } + + res.Send(w, http.StatusOK, appStats) +} diff --git a/apps/api/internal/api/handlers/auth.go b/apps/api/internal/api/handlers/auth.go index f5a0ea5e..957a0395 100644 --- a/apps/api/internal/api/handlers/auth.go +++ b/apps/api/internal/api/handlers/auth.go @@ -39,7 +39,7 @@ func NewAuthHandler(authService *services.AuthService, cfg *config.Config, logge // @Success 200 {object} middleware.UserContext // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." // @Failure 500 {object} response.ErrorResponse -// @Router /auth/me [get] +// @Router /auth/me [get] func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { user, err := h.authService.GetMe(r.Context()) if err != nil { diff --git a/apps/api/internal/api/handlers/email.go b/apps/api/internal/api/handlers/email.go index a684fe7f..20cf077e 100644 --- a/apps/api/internal/api/handlers/email.go +++ b/apps/api/internal/api/handlers/email.go @@ -39,7 +39,7 @@ type QueueConfirmationEmailRequest struct { // @Tags Email // @Accept json // @Produce json -// @Param request body QueueEmailRequest true "Email data" +// @Param request body QueueTextEmailRequest true "Email data" // @Success 201 {object} string "OK: Email request queued" // @Failure 400 {object} response.ErrorResponse "Bad request/Malformed request. The email request is potentially invalid." // @Failure 500 {object} response.ErrorResponse "Server Error: The server went kaput while queueing email sending" diff --git a/apps/api/internal/db/queries/stats.sql b/apps/api/internal/db/queries/stats.sql new file mode 100644 index 00000000..0b7458bb --- /dev/null +++ b/apps/api/internal/db/queries/stats.sql @@ -0,0 +1,76 @@ +-- Queries used for statistics, mainly used by overview dashboards etc + +-- name: GetApplicationGenderSplit :one +SELECT + COUNT(*) FILTER (WHERE application->>'gender' = 'man') AS male, + COUNT(*) FILTER (WHERE application->>'gender' = 'woman') AS female, + COUNT(*) FILTER (WHERE application->>'gender' = 'non-binary') AS non_binary, + COUNT(*) FILTER (WHERE application->>'gender' = '') AS other +FROM applications +WHERE status = 'submitted' + AND event_id = $1; + +-- name: GetApplicationAgeSplit :one +SELECT + COUNT(*) FILTER (WHERE (application->>'age')::int < 18) AS underage, + COUNT(*) FILTER (WHERE (application->>'age')::int = 18) AS age_18, + COUNT(*) FILTER (WHERE (application->>'age')::int = 19) AS age_19, + COUNT(*) FILTER (WHERE (application->>'age')::int = 20) AS age_20, + COUNT(*) FILTER (WHERE (application->>'age')::int = 21) AS age_21, + COUNT(*) FILTER (WHERE (application->>'age')::int = 22) AS age_22, + COUNT(*) FILTER (WHERE (application->>'age')::int >= 23) AS age_23_plus +FROM applications +WHERE status = 'submitted' + AND event_id = $1; + +-- name: GetApplicationRaceSplit :many +SELECT + CASE + WHEN application->>'race' IS NOT NULL AND application->>'race' <> '' THEN application->>'race' + WHEN application->>'race-other' IS NOT NULL AND application->>'race-other' <> '' THEN application->>'race-other' + ELSE 'prefer_not_to_say' + END AS race_group, + COUNT(*) AS count +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY + CASE + WHEN application->>'race' IS NOT NULL AND application->>'race' <> '' THEN application->>'race' + WHEN application->>'race-other' IS NOT NULL AND application->>'race-other' <> '' THEN application->>'race-other' + ELSE 'prefer_not_to_say' + END +ORDER BY count DESC; + +-- name: GetApplicationSchoolSplit :many +SELECT + (application->>'school')::text AS school, + COUNT(*) AS count +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY (application->>'school')::text +ORDER BY count DESC; + +-- name: GetApplicationMajorSplit :many +SELECT + trim(major) AS major, + COUNT(*) AS count +FROM applications, +LATERAL unnest(string_to_array(application->>'majors', ',')) AS major +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY trim(major) +ORDER BY count DESC; + +-- name: GetApplicationStatusSplit :one +SELECT + COUNT(*) FILTER (WHERE status = 'started') AS started, + COUNT(*) FILTER (WHERE status = 'submitted') AS submitted, + COUNT(*) FILTER (WHERE status = 'under_review') AS under_review, + COUNT(*) FILTER (WHERE status = 'accepted') AS accepted, + COUNT(*) FILTER (WHERE status = 'rejected') AS rejected, + COUNT(*) FILTER (WHERE status = 'waitlisted') AS waitlisted, + COUNT(*) FILTER (WHERE status = 'withdrawn') AS withdrawn +FROM applications +WHERE event_id = $1; diff --git a/apps/api/internal/db/repository/application.go b/apps/api/internal/db/repository/application.go index f6195dcd..48c64403 100644 --- a/apps/api/internal/db/repository/application.go +++ b/apps/api/internal/db/repository/application.go @@ -113,3 +113,28 @@ func (r *ApplicationRepository) SaveApplication(ctx context.Context, data any, u return nil } + +// Application statistics (Staff Dashboards) +func (r *ApplicationRepository) GetSubmittedApplicationGenders(ctx context.Context, eventId uuid.UUID) (sqlc.GetApplicationGenderSplitRow, error) { + return r.db.Query.GetApplicationGenderSplit(ctx, eventId) +} + +func (r *ApplicationRepository) GetSubmittedApplicationRaces(ctx context.Context, eventId uuid.UUID) ([]sqlc.GetApplicationRaceSplitRow, error) { + return r.db.Query.GetApplicationRaceSplit(ctx, eventId) +} + +func (r *ApplicationRepository) GetSubmittedApplicationAges(ctx context.Context, eventId uuid.UUID) (sqlc.GetApplicationAgeSplitRow, error) { + return r.db.Query.GetApplicationAgeSplit(ctx, eventId) +} + +func (r *ApplicationRepository) GetSubmittedApplicationMajors(ctx context.Context, eventId uuid.UUID) ([]sqlc.GetApplicationMajorSplitRow, error) { + return r.db.Query.GetApplicationMajorSplit(ctx, eventId) +} + +func (r *ApplicationRepository) GetSubmittedApplicationSchools(ctx context.Context, eventId uuid.UUID) ([]sqlc.GetApplicationSchoolSplitRow, error) { + return r.db.Query.GetApplicationSchoolSplit(ctx, eventId) +} + +func (r *ApplicationRepository) GetApplicationStatuses(ctx context.Context, eventId uuid.UUID) (sqlc.GetApplicationStatusSplitRow, error) { + return r.db.Query.GetApplicationStatusSplit(ctx, eventId) +} diff --git a/apps/api/internal/db/sqlc/stats.sql.go b/apps/api/internal/db/sqlc/stats.sql.go new file mode 100644 index 00000000..8dc74988 --- /dev/null +++ b/apps/api/internal/db/sqlc/stats.sql.go @@ -0,0 +1,239 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: stats.sql + +package sqlc + +import ( + "context" + + "github.com/google/uuid" +) + +const getApplicationAgeSplit = `-- name: GetApplicationAgeSplit :one +SELECT + COUNT(*) FILTER (WHERE (application->>'age')::int < 18) AS underage, + COUNT(*) FILTER (WHERE (application->>'age')::int = 18) AS age_18, + COUNT(*) FILTER (WHERE (application->>'age')::int = 19) AS age_19, + COUNT(*) FILTER (WHERE (application->>'age')::int = 20) AS age_20, + COUNT(*) FILTER (WHERE (application->>'age')::int = 21) AS age_21, + COUNT(*) FILTER (WHERE (application->>'age')::int = 22) AS age_22, + COUNT(*) FILTER (WHERE (application->>'age')::int >= 23) AS age_23_plus +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +` + +type GetApplicationAgeSplitRow struct { + Underage int64 `json:"underage"` + Age18 int64 `json:"age_18"` + Age19 int64 `json:"age_19"` + Age20 int64 `json:"age_20"` + Age21 int64 `json:"age_21"` + Age22 int64 `json:"age_22"` + Age23Plus int64 `json:"age_23_plus"` +} + +func (q *Queries) GetApplicationAgeSplit(ctx context.Context, eventID uuid.UUID) (GetApplicationAgeSplitRow, error) { + row := q.db.QueryRow(ctx, getApplicationAgeSplit, eventID) + var i GetApplicationAgeSplitRow + err := row.Scan( + &i.Underage, + &i.Age18, + &i.Age19, + &i.Age20, + &i.Age21, + &i.Age22, + &i.Age23Plus, + ) + return i, err +} + +const getApplicationGenderSplit = `-- name: GetApplicationGenderSplit :one + +SELECT + COUNT(*) FILTER (WHERE application->>'gender' = 'man') AS male, + COUNT(*) FILTER (WHERE application->>'gender' = 'woman') AS female, + COUNT(*) FILTER (WHERE application->>'gender' = 'non-binary') AS non_binary, + COUNT(*) FILTER (WHERE application->>'gender' = '') AS other +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +` + +type GetApplicationGenderSplitRow struct { + Male int64 `json:"male"` + Female int64 `json:"female"` + NonBinary int64 `json:"non_binary"` + Other int64 `json:"other"` +} + +// Queries used for statistics, mainly used by overview dashboards etc +func (q *Queries) GetApplicationGenderSplit(ctx context.Context, eventID uuid.UUID) (GetApplicationGenderSplitRow, error) { + row := q.db.QueryRow(ctx, getApplicationGenderSplit, eventID) + var i GetApplicationGenderSplitRow + err := row.Scan( + &i.Male, + &i.Female, + &i.NonBinary, + &i.Other, + ) + return i, err +} + +const getApplicationMajorSplit = `-- name: GetApplicationMajorSplit :many +SELECT + trim(major) AS major, + COUNT(*) AS count +FROM applications, +LATERAL unnest(string_to_array(application->>'majors', ',')) AS major +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY trim(major) +ORDER BY count DESC +` + +type GetApplicationMajorSplitRow struct { + Major string `json:"major"` + Count int64 `json:"count"` +} + +func (q *Queries) GetApplicationMajorSplit(ctx context.Context, eventID uuid.UUID) ([]GetApplicationMajorSplitRow, error) { + rows, err := q.db.Query(ctx, getApplicationMajorSplit, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetApplicationMajorSplitRow{} + for rows.Next() { + var i GetApplicationMajorSplitRow + if err := rows.Scan(&i.Major, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getApplicationRaceSplit = `-- name: GetApplicationRaceSplit :many +SELECT + CASE + WHEN application->>'race' IS NOT NULL AND application->>'race' <> '' THEN application->>'race' + WHEN application->>'race-other' IS NOT NULL AND application->>'race-other' <> '' THEN application->>'race-other' + ELSE 'prefer_not_to_say' + END AS race_group, + COUNT(*) AS count +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY + CASE + WHEN application->>'race' IS NOT NULL AND application->>'race' <> '' THEN application->>'race' + WHEN application->>'race-other' IS NOT NULL AND application->>'race-other' <> '' THEN application->>'race-other' + ELSE 'prefer_not_to_say' + END +ORDER BY count DESC +` + +type GetApplicationRaceSplitRow struct { + RaceGroup string `json:"race_group"` + Count int64 `json:"count"` +} + +func (q *Queries) GetApplicationRaceSplit(ctx context.Context, eventID uuid.UUID) ([]GetApplicationRaceSplitRow, error) { + rows, err := q.db.Query(ctx, getApplicationRaceSplit, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetApplicationRaceSplitRow{} + for rows.Next() { + var i GetApplicationRaceSplitRow + if err := rows.Scan(&i.RaceGroup, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getApplicationSchoolSplit = `-- name: GetApplicationSchoolSplit :many +SELECT + (application->>'school')::text AS school, + COUNT(*) AS count +FROM applications +WHERE status = 'submitted' + AND event_id = $1 +GROUP BY (application->>'school')::text +ORDER BY count DESC +` + +type GetApplicationSchoolSplitRow struct { + School string `json:"school"` + Count int64 `json:"count"` +} + +func (q *Queries) GetApplicationSchoolSplit(ctx context.Context, eventID uuid.UUID) ([]GetApplicationSchoolSplitRow, error) { + rows, err := q.db.Query(ctx, getApplicationSchoolSplit, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetApplicationSchoolSplitRow{} + for rows.Next() { + var i GetApplicationSchoolSplitRow + if err := rows.Scan(&i.School, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getApplicationStatusSplit = `-- name: GetApplicationStatusSplit :one +SELECT + COUNT(*) FILTER (WHERE status = 'started') AS started, + COUNT(*) FILTER (WHERE status = 'submitted') AS submitted, + COUNT(*) FILTER (WHERE status = 'under_review') AS under_review, + COUNT(*) FILTER (WHERE status = 'accepted') AS accepted, + COUNT(*) FILTER (WHERE status = 'rejected') AS rejected, + COUNT(*) FILTER (WHERE status = 'waitlisted') AS waitlisted, + COUNT(*) FILTER (WHERE status = 'withdrawn') AS withdrawn +FROM applications +WHERE event_id = $1 +` + +type GetApplicationStatusSplitRow struct { + Started int64 `json:"started"` + Submitted int64 `json:"submitted"` + UnderReview int64 `json:"under_review"` + Accepted int64 `json:"accepted"` + Rejected int64 `json:"rejected"` + Waitlisted int64 `json:"waitlisted"` + Withdrawn int64 `json:"withdrawn"` +} + +func (q *Queries) GetApplicationStatusSplit(ctx context.Context, eventID uuid.UUID) (GetApplicationStatusSplitRow, error) { + row := q.db.QueryRow(ctx, getApplicationStatusSplit, eventID) + var i GetApplicationStatusSplitRow + err := row.Scan( + &i.Started, + &i.Submitted, + &i.UnderReview, + &i.Accepted, + &i.Rejected, + &i.Waitlisted, + &i.Withdrawn, + ) + return i, err +} diff --git a/apps/api/internal/services/application.go b/apps/api/internal/services/application.go index 9e847c49..234c96f0 100644 --- a/apps/api/internal/services/application.go +++ b/apps/api/internal/services/application.go @@ -13,6 +13,11 @@ import ( "github.com/swamphacks/core/apps/api/internal/db/sqlc" "github.com/swamphacks/core/apps/api/internal/ptr" "github.com/swamphacks/core/apps/api/internal/storage" + "golang.org/x/sync/errgroup" +) + +var ( + ErrGetApplicationStatistics = errors.New("failed to aggregate application stats") ) // TODO: figure out a way to create the submission fields dynamically using the json form files with proper validation. @@ -24,6 +29,7 @@ type ApplicationSubmissionFields struct { Phone string `json:"phone" validate:"required,len=10"` PreferredEmail string `json:"preferredEmail" validate:"required,email"` UniversityEmail string `json:"universityEmail" validate:"required,email"` + Country string `json:"country" validate:"required"` Gender string `json:"gender"` GenderOther string `json:"gender-other"` Pronouns string `json:"pronouns"` @@ -206,3 +212,74 @@ func (s *ApplicationService) SaveApplication(ctx context.Context, data any, user return nil } + +type ApplicationStatistics struct { + GenderStatistics sqlc.GetApplicationGenderSplitRow `json:"gender_stats"` + AgeStatistics sqlc.GetApplicationAgeSplitRow `json:"age_stats"` + RaceStatistics []sqlc.GetApplicationRaceSplitRow `json:"race_stats"` + MajorStatistics []sqlc.GetApplicationMajorSplitRow `json:"major_stats"` + SchoolStatistics []sqlc.GetApplicationSchoolSplitRow `json:"school_stats"` + StatusStatistics sqlc.GetApplicationStatusSplitRow `json:"status_stats"` +} + +func (s *ApplicationService) GetApplicationStatistics(ctx context.Context, eventId uuid.UUID) (*ApplicationStatistics, error) { + g, ctx := errgroup.WithContext(ctx) + + var genderStats sqlc.GetApplicationGenderSplitRow + var ageStats sqlc.GetApplicationAgeSplitRow + var raceStats []sqlc.GetApplicationRaceSplitRow + var majorStats []sqlc.GetApplicationMajorSplitRow + var schoolStats []sqlc.GetApplicationSchoolSplitRow + var statusStats sqlc.GetApplicationStatusSplitRow + + g.Go(func() error { + var err error + genderStats, err = s.appRepo.GetSubmittedApplicationGenders(ctx, eventId) + return err + }) + + g.Go(func() error { + var err error + ageStats, err = s.appRepo.GetSubmittedApplicationAges(ctx, eventId) + return err + }) + + g.Go(func() error { + var err error + majorStats, err = s.appRepo.GetSubmittedApplicationMajors(ctx, eventId) + return err + }) + + g.Go(func() error { + var err error + raceStats, err = s.appRepo.GetSubmittedApplicationRaces(ctx, eventId) + return err + }) + + g.Go(func() error { + var err error + schoolStats, err = s.appRepo.GetSubmittedApplicationSchools(ctx, eventId) + return err + }) + + g.Go(func() error { + var err error + statusStats, err = s.appRepo.GetApplicationStatuses(ctx, eventId) + return err + }) + + if err := g.Wait(); err != nil { + s.logger.Err(err).Msg("Something went wrong while getting application statistics") + return nil, ErrGetApplicationStatistics + } + + return &ApplicationStatistics{ + GenderStatistics: genderStats, + AgeStatistics: ageStats, + RaceStatistics: raceStats, + MajorStatistics: majorStats, + SchoolStatistics: schoolStats, + StatusStatistics: statusStats, + }, nil + +} diff --git a/apps/web/package.json b/apps/web/package.json index bd64c351..62e3291c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "browser-image-compression": "^2.0.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "echarts": "^6.0.0", "js-cookie": "^3.0.5", "ky": "^1.8.1", "lodash.debounce": "^4.0.8", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 235eeed3..8eb7dd57 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + echarts: + specifier: ^6.0.0 + version: 6.0.0 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -2712,6 +2715,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + electron-to-chromium@1.5.178: resolution: {integrity: sha512-wObbz/ar3Bc6e4X5vf0iO8xTN8YAjN/tgiAOJLr7yjYFtP9wAjq8Mb5h0yn6kResir+VYx2DXBj9NNobs0ETSA==} @@ -4173,6 +4179,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4486,6 +4495,9 @@ packages: zod@4.0.5: resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + snapshots: '@adobe/css-tools@4.4.3': {} @@ -7582,6 +7594,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + electron-to-chromium@1.5.178: {} electron-to-chromium@1.5.222: {} @@ -9099,6 +9116,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.3.0: {} + tslib@2.8.1: {} tsx@4.20.3: @@ -9410,3 +9429,7 @@ snapshots: zod@3.25.76: {} zod@4.0.5: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/apps/web/public/assets/countries.json b/apps/web/public/assets/countries.json new file mode 100644 index 00000000..6ca635bb --- /dev/null +++ b/apps/web/public/assets/countries.json @@ -0,0 +1,245 @@ +[ + {"name": "Afghanistan", "code": "AF"}, + {"name": "Åland Islands", "code": "AX"}, + {"name": "Albania", "code": "AL"}, + {"name": "Algeria", "code": "DZ"}, + {"name": "American Samoa", "code": "AS"}, + {"name": "AndorrA", "code": "AD"}, + {"name": "Angola", "code": "AO"}, + {"name": "Anguilla", "code": "AI"}, + {"name": "Antarctica", "code": "AQ"}, + {"name": "Antigua and Barbuda", "code": "AG"}, + {"name": "Argentina", "code": "AR"}, + {"name": "Armenia", "code": "AM"}, + {"name": "Aruba", "code": "AW"}, + {"name": "Australia", "code": "AU"}, + {"name": "Austria", "code": "AT"}, + {"name": "Azerbaijan", "code": "AZ"}, + {"name": "Bahamas", "code": "BS"}, + {"name": "Bahrain", "code": "BH"}, + {"name": "Bangladesh", "code": "BD"}, + {"name": "Barbados", "code": "BB"}, + {"name": "Belarus", "code": "BY"}, + {"name": "Belgium", "code": "BE"}, + {"name": "Belize", "code": "BZ"}, + {"name": "Benin", "code": "BJ"}, + {"name": "Bermuda", "code": "BM"}, + {"name": "Bhutan", "code": "BT"}, + {"name": "Bolivia", "code": "BO"}, + {"name": "Bosnia and Herzegovina", "code": "BA"}, + {"name": "Botswana", "code": "BW"}, + {"name": "Bouvet Island", "code": "BV"}, + {"name": "Brazil", "code": "BR"}, + {"name": "British Indian Ocean Territory", "code": "IO"}, + {"name": "Brunei Darussalam", "code": "BN"}, + {"name": "Bulgaria", "code": "BG"}, + {"name": "Burkina Faso", "code": "BF"}, + {"name": "Burundi", "code": "BI"}, + {"name": "Cambodia", "code": "KH"}, + {"name": "Cameroon", "code": "CM"}, + {"name": "Canada", "code": "CA"}, + {"name": "Cape Verde", "code": "CV"}, + {"name": "Cayman Islands", "code": "KY"}, + {"name": "Central African Republic", "code": "CF"}, + {"name": "Chad", "code": "TD"}, + {"name": "Chile", "code": "CL"}, + {"name": "China", "code": "CN"}, + {"name": "Christmas Island", "code": "CX"}, + {"name": "Cocos (Keeling) Islands", "code": "CC"}, + {"name": "Colombia", "code": "CO"}, + {"name": "Comoros", "code": "KM"}, + {"name": "Congo", "code": "CG"}, + {"name": "Congo, The Democratic Republic of the", "code": "CD"}, + {"name": "Cook Islands", "code": "CK"}, + {"name": "Costa Rica", "code": "CR"}, + {"name": "Cote D\"Ivoire", "code": "CI"}, + {"name": "Croatia", "code": "HR"}, + {"name": "Cuba", "code": "CU"}, + {"name": "Cyprus", "code": "CY"}, + {"name": "Czech Republic", "code": "CZ"}, + {"name": "Denmark", "code": "DK"}, + {"name": "Djibouti", "code": "DJ"}, + {"name": "Dominica", "code": "DM"}, + {"name": "Dominican Republic", "code": "DO"}, + {"name": "Ecuador", "code": "EC"}, + {"name": "Egypt", "code": "EG"}, + {"name": "El Salvador", "code": "SV"}, + {"name": "Equatorial Guinea", "code": "GQ"}, + {"name": "Eritrea", "code": "ER"}, + {"name": "Estonia", "code": "EE"}, + {"name": "Ethiopia", "code": "ET"}, + {"name": "Falkland Islands (Malvinas)", "code": "FK"}, + {"name": "Faroe Islands", "code": "FO"}, + {"name": "Fiji", "code": "FJ"}, + {"name": "Finland", "code": "FI"}, + {"name": "France", "code": "FR"}, + {"name": "French Guiana", "code": "GF"}, + {"name": "French Polynesia", "code": "PF"}, + {"name": "French Southern Territories", "code": "TF"}, + {"name": "Gabon", "code": "GA"}, + {"name": "Gambia", "code": "GM"}, + {"name": "Georgia", "code": "GE"}, + {"name": "Germany", "code": "DE"}, + {"name": "Ghana", "code": "GH"}, + {"name": "Gibraltar", "code": "GI"}, + {"name": "Greece", "code": "GR"}, + {"name": "Greenland", "code": "GL"}, + {"name": "Grenada", "code": "GD"}, + {"name": "Guadeloupe", "code": "GP"}, + {"name": "Guam", "code": "GU"}, + {"name": "Guatemala", "code": "GT"}, + {"name": "Guernsey", "code": "GG"}, + {"name": "Guinea", "code": "GN"}, + {"name": "Guinea-Bissau", "code": "GW"}, + {"name": "Guyana", "code": "GY"}, + {"name": "Haiti", "code": "HT"}, + {"name": "Heard Island and Mcdonald Islands", "code": "HM"}, + {"name": "Holy See (Vatican City State)", "code": "VA"}, + {"name": "Honduras", "code": "HN"}, + {"name": "Hong Kong", "code": "HK"}, + {"name": "Hungary", "code": "HU"}, + {"name": "Iceland", "code": "IS"}, + {"name": "India", "code": "IN"}, + {"name": "Indonesia", "code": "ID"}, + {"name": "Iran, Islamic Republic Of", "code": "IR"}, + {"name": "Iraq", "code": "IQ"}, + {"name": "Ireland", "code": "IE"}, + {"name": "Isle of Man", "code": "IM"}, + {"name": "Israel", "code": "IL"}, + {"name": "Italy", "code": "IT"}, + {"name": "Jamaica", "code": "JM"}, + {"name": "Japan", "code": "JP"}, + {"name": "Jersey", "code": "JE"}, + {"name": "Jordan", "code": "JO"}, + {"name": "Kazakhstan", "code": "KZ"}, + {"name": "Kenya", "code": "KE"}, + {"name": "Kiribati", "code": "KI"}, + {"name": "Korea, Democratic People\"S Republic of", "code": "KP"}, + {"name": "Korea, Republic of", "code": "KR"}, + {"name": "Kuwait", "code": "KW"}, + {"name": "Kyrgyzstan", "code": "KG"}, + {"name": "Lao People\"S Democratic Republic", "code": "LA"}, + {"name": "Latvia", "code": "LV"}, + {"name": "Lebanon", "code": "LB"}, + {"name": "Lesotho", "code": "LS"}, + {"name": "Liberia", "code": "LR"}, + {"name": "Libyan Arab Jamahiriya", "code": "LY"}, + {"name": "Liechtenstein", "code": "LI"}, + {"name": "Lithuania", "code": "LT"}, + {"name": "Luxembourg", "code": "LU"}, + {"name": "Macao", "code": "MO"}, + {"name": "Macedonia, The Former Yugoslav Republic of", "code": "MK"}, + {"name": "Madagascar", "code": "MG"}, + {"name": "Malawi", "code": "MW"}, + {"name": "Malaysia", "code": "MY"}, + {"name": "Maldives", "code": "MV"}, + {"name": "Mali", "code": "ML"}, + {"name": "Malta", "code": "MT"}, + {"name": "Marshall Islands", "code": "MH"}, + {"name": "Martinique", "code": "MQ"}, + {"name": "Mauritania", "code": "MR"}, + {"name": "Mauritius", "code": "MU"}, + {"name": "Mayotte", "code": "YT"}, + {"name": "Mexico", "code": "MX"}, + {"name": "Micronesia, Federated States of", "code": "FM"}, + {"name": "Moldova, Republic of", "code": "MD"}, + {"name": "Monaco", "code": "MC"}, + {"name": "Mongolia", "code": "MN"}, + {"name": "Montserrat", "code": "MS"}, + {"name": "Morocco", "code": "MA"}, + {"name": "Mozambique", "code": "MZ"}, + {"name": "Myanmar", "code": "MM"}, + {"name": "Namibia", "code": "NA"}, + {"name": "Nauru", "code": "NR"}, + {"name": "Nepal", "code": "NP"}, + {"name": "Netherlands", "code": "NL"}, + {"name": "Netherlands Antilles", "code": "AN"}, + {"name": "New Caledonia", "code": "NC"}, + {"name": "New Zealand", "code": "NZ"}, + {"name": "Nicaragua", "code": "NI"}, + {"name": "Niger", "code": "NE"}, + {"name": "Nigeria", "code": "NG"}, + {"name": "Niue", "code": "NU"}, + {"name": "Norfolk Island", "code": "NF"}, + {"name": "Northern Mariana Islands", "code": "MP"}, + {"name": "Norway", "code": "NO"}, + {"name": "Oman", "code": "OM"}, + {"name": "Pakistan", "code": "PK"}, + {"name": "Palau", "code": "PW"}, + {"name": "Palestinian Territory, Occupied", "code": "PS"}, + {"name": "Panama", "code": "PA"}, + {"name": "Papua New Guinea", "code": "PG"}, + {"name": "Paraguay", "code": "PY"}, + {"name": "Peru", "code": "PE"}, + {"name": "Philippines", "code": "PH"}, + {"name": "Pitcairn", "code": "PN"}, + {"name": "Poland", "code": "PL"}, + {"name": "Portugal", "code": "PT"}, + {"name": "Puerto Rico", "code": "PR"}, + {"name": "Qatar", "code": "QA"}, + {"name": "Reunion", "code": "RE"}, + {"name": "Romania", "code": "RO"}, + {"name": "Russian Federation", "code": "RU"}, + {"name": "RWANDA", "code": "RW"}, + {"name": "Saint Helena", "code": "SH"}, + {"name": "Saint Kitts and Nevis", "code": "KN"}, + {"name": "Saint Lucia", "code": "LC"}, + {"name": "Saint Pierre and Miquelon", "code": "PM"}, + {"name": "Saint Vincent and the Grenadines", "code": "VC"}, + {"name": "Samoa", "code": "WS"}, + {"name": "San Marino", "code": "SM"}, + {"name": "Sao Tome and Principe", "code": "ST"}, + {"name": "Saudi Arabia", "code": "SA"}, + {"name": "Senegal", "code": "SN"}, + {"name": "Serbia and Montenegro", "code": "CS"}, + {"name": "Seychelles", "code": "SC"}, + {"name": "Sierra Leone", "code": "SL"}, + {"name": "Singapore", "code": "SG"}, + {"name": "Slovakia", "code": "SK"}, + {"name": "Slovenia", "code": "SI"}, + {"name": "Solomon Islands", "code": "SB"}, + {"name": "Somalia", "code": "SO"}, + {"name": "South Africa", "code": "ZA"}, + {"name": "South Georgia and the South Sandwich Islands", "code": "GS"}, + {"name": "Spain", "code": "ES"}, + {"name": "Sri Lanka", "code": "LK"}, + {"name": "Sudan", "code": "SD"}, + {"name": "Suriname", "code": "SR"}, + {"name": "Svalbard and Jan Mayen", "code": "SJ"}, + {"name": "Swaziland", "code": "SZ"}, + {"name": "Sweden", "code": "SE"}, + {"name": "Switzerland", "code": "CH"}, + {"name": "Syrian Arab Republic", "code": "SY"}, + {"name": "Taiwan, Province of China", "code": "TW"}, + {"name": "Tajikistan", "code": "TJ"}, + {"name": "Tanzania, United Republic of", "code": "TZ"}, + {"name": "Thailand", "code": "TH"}, + {"name": "Timor-Leste", "code": "TL"}, + {"name": "Togo", "code": "TG"}, + {"name": "Tokelau", "code": "TK"}, + {"name": "Tonga", "code": "TO"}, + {"name": "Trinidad and Tobago", "code": "TT"}, + {"name": "Tunisia", "code": "TN"}, + {"name": "Turkey", "code": "TR"}, + {"name": "Turkmenistan", "code": "TM"}, + {"name": "Turks and Caicos Islands", "code": "TC"}, + {"name": "Tuvalu", "code": "TV"}, + {"name": "Uganda", "code": "UG"}, + {"name": "Ukraine", "code": "UA"}, + {"name": "United Arab Emirates", "code": "AE"}, + {"name": "United Kingdom", "code": "GB"}, + {"name": "United States", "code": "US"}, + {"name": "United States Minor Outlying Islands", "code": "UM"}, + {"name": "Uruguay", "code": "UY"}, + {"name": "Uzbekistan", "code": "UZ"}, + {"name": "Vanuatu", "code": "VU"}, + {"name": "Venezuela", "code": "VE"}, + {"name": "Viet Nam", "code": "VN"}, + {"name": "Virgin Islands, British", "code": "VG"}, + {"name": "Virgin Islands, U.S.", "code": "VI"}, + {"name": "Wallis and Futuna", "code": "WF"}, + {"name": "Western Sahara", "code": "EH"}, + {"name": "Yemen", "code": "YE"}, + {"name": "Zambia", "code": "ZM"}, + {"name": "Zimbabwe", "code": "ZW"} +] \ No newline at end of file diff --git a/apps/web/src/components/ECharts.tsx b/apps/web/src/components/ECharts.tsx new file mode 100644 index 00000000..f4568c85 --- /dev/null +++ b/apps/web/src/components/ECharts.tsx @@ -0,0 +1,77 @@ +import * as echarts from "echarts/core"; + +import { CanvasRenderer } from "echarts/renderers"; + +import { + TitleComponent, + TooltipComponent, + GridComponent, + // Dataset + DatasetComponent, + // Built-in transform (filter, sort) + TransformComponent, +} from "echarts/components"; + +import type { ComposeOption } from "echarts/core"; + +import { PieChart } from "echarts/charts"; + +import { LabelLayout, UniversalTransition } from "echarts/features"; + +import type { PieSeriesOption } from "echarts/charts"; + +import type { + // The component option types are defined with the ComponentOption suffix + TitleComponentOption, + TooltipComponentOption, + GridComponentOption, + DatasetComponentOption, +} from "echarts/components"; +import { useEffect, useRef } from "react"; + +type ECOption = ComposeOption< + | PieSeriesOption + | TitleComponentOption + | TooltipComponentOption + | GridComponentOption + | DatasetComponentOption +>; + +echarts.use([ + TitleComponent, + TooltipComponent, + GridComponent, + DatasetComponent, + TransformComponent, + PieChart, + LabelLayout, + UniversalTransition, + CanvasRenderer, +]); + +interface Props { + option: ECOption; + className?: string; +} + +export default function ECharts({ option, className }: Props) { + const chartRef = useRef(null); + + useEffect(() => { + const chartInstance = echarts.init(chartRef.current); + + chartInstance.setOption(option); + + const handleResize = () => { + chartInstance.resize(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + chartInstance.dispose(); + }; + }, [option]); + + return
; +} diff --git a/apps/web/src/features/EventOverview/components/ApplicationAgeChart.tsx b/apps/web/src/features/EventOverview/components/ApplicationAgeChart.tsx new file mode 100644 index 00000000..f170fad0 --- /dev/null +++ b/apps/web/src/features/EventOverview/components/ApplicationAgeChart.tsx @@ -0,0 +1,107 @@ +import ECharts from "@/components/ECharts"; +import { useTheme } from "@/components/ThemeProvider"; +import { Card } from "@/components/ui/Card"; +import type { components } from "@/lib/openapi/schema"; + +interface Props { + data?: components["schemas"]["sqlc.GetApplicationAgeSplitRow"]; +} + +export default function ApplicationAgeChart({ data }: Props) { + const { theme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + if (!data) { + return ( +
+

Loading...

+
+ ); + } + + return ( + + { + const p = Array.isArray(params) ? params[0] : params; + const percent = p.percent?.toFixed(1); + + return `${p.marker} ${p.name}: ${p.value} (${percent}%)`; + }, + }, + series: [ + { + type: "pie", + center: ["50%", "55%"], + label: { + show: true, + color: isDark ? "#FFFFFF" : "#000000", + }, + data: [ + { + value: data.underage, + name: "<18 yrs", + }, + { + value: data.age_18, + name: "18 yrs", + }, + { + value: data.age_19, + name: "19 yrs", + }, + { + value: data.age_20, + name: "20 yrs", + }, + { + value: data.age_21, + name: "21 yrs", + }, + { + value: data.age_22, + name: "22 yrs", + }, + { + value: data.age_23_plus, + name: "23+ yrs", + }, + ].filter((item) => item.value > 0), + }, + ], + }} + /> + + ); +} diff --git a/apps/web/src/features/EventOverview/components/ApplicationGenderChart.tsx b/apps/web/src/features/EventOverview/components/ApplicationGenderChart.tsx new file mode 100644 index 00000000..2b1b7cb3 --- /dev/null +++ b/apps/web/src/features/EventOverview/components/ApplicationGenderChart.tsx @@ -0,0 +1,98 @@ +import ECharts from "@/components/ECharts"; +import { useTheme } from "@/components/ThemeProvider"; +import { Card } from "@/components/ui/Card"; +import type { components } from "@/lib/openapi/schema"; + +interface Props { + data?: components["schemas"]["sqlc.GetApplicationGenderSplitRow"]; +} + +export default function ApplicationGenderChart({ data }: Props) { + const { theme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + if (!data) { + return ( +
+

Loading...

+
+ ); + } + + return ( + + { + const p = Array.isArray(params) ? params[0] : params; + const percent = p.percent?.toFixed(1); + + return `${p.marker} ${p.name}: ${p.value} (${percent}%)`; + }, + }, + series: [ + { + type: "pie", + center: ["50%", "55%"], + label: { + show: true, + color: isDark ? "#FFFFFF" : "#000000", + }, + data: [ + { + value: data.male, + name: "Male", + itemStyle: { + color: "#4ECDC4", + }, + }, + { + value: data.female, + name: "Female", + itemStyle: { + color: "#FF6B6B", + }, + }, + { + value: data.non_binary, + name: "Non-binary", + itemStyle: { + color: "#F7B801", + }, + }, + { + value: data.other, + name: "Other", + itemStyle: { + color: "#9B59B6", + }, + }, + ].filter((item) => item.value > 0), + }, + ], + }} + /> + + ); +} diff --git a/apps/web/src/features/EventOverview/components/ApplicationMajorsChart.tsx b/apps/web/src/features/EventOverview/components/ApplicationMajorsChart.tsx new file mode 100644 index 00000000..41a9e4e7 --- /dev/null +++ b/apps/web/src/features/EventOverview/components/ApplicationMajorsChart.tsx @@ -0,0 +1,71 @@ +import ECharts from "@/components/ECharts"; +import { useTheme } from "@/components/ThemeProvider"; +import type { components } from "@/lib/openapi/schema"; + +interface Props { + data?: components["schemas"]["sqlc.GetApplicationMajorSplitRow"][]; +} + +export default function ApplicationMajorsChart({ data }: Props) { + const { theme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + if (!data) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+ { + const p = Array.isArray(params) ? params[0] : params; + const percent = p.percent?.toFixed(1); + + return `${p.marker} ${p.name}: ${p.value} (${percent}%)`; + }, + }, + series: [ + { + type: "pie", + center: ["50%", "55%"], + label: { + show: true, + color: isDark ? "#FFFFFF" : "#000000", + }, + data: data.map((row) => ({ + value: row.count, + name: row.major, + })), + }, + ], + }} + /> +
+ ); +} diff --git a/apps/web/src/features/EventOverview/components/ApplicationRaceChart.tsx b/apps/web/src/features/EventOverview/components/ApplicationRaceChart.tsx new file mode 100644 index 00000000..a8908edc --- /dev/null +++ b/apps/web/src/features/EventOverview/components/ApplicationRaceChart.tsx @@ -0,0 +1,81 @@ +import ECharts from "@/components/ECharts"; +import { useTheme } from "@/components/ThemeProvider"; +import { Card } from "@/components/ui/Card"; +import type { components } from "@/lib/openapi/schema"; + +interface Props { + data?: components["schemas"]["sqlc.GetApplicationRaceSplitRow"][]; +} + +export default function ApplicationRaceChart({ data }: Props) { + const { theme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + if (!data) { + return ( +
+

Loading...

+
+ ); + } + + return ( + + { + const p = Array.isArray(params) ? params[0] : params; + const percent = p.percent?.toFixed(1); + + return `${p.marker} ${p.name}: ${p.value} (${percent}%)`; + }, + }, + series: [ + { + type: "pie", + center: ["50%", "55%"], + label: { + show: true, + color: isDark ? "#FFFFFF" : "#000000", + }, + data: data?.map((item) => ({ + value: item.count, + name: item.race_group, + })), + }, + ], + }} + /> + + ); +} diff --git a/apps/web/src/features/EventOverview/components/ApplicationSchoolsChart.tsx b/apps/web/src/features/EventOverview/components/ApplicationSchoolsChart.tsx new file mode 100644 index 00000000..deed0c8e --- /dev/null +++ b/apps/web/src/features/EventOverview/components/ApplicationSchoolsChart.tsx @@ -0,0 +1,80 @@ +import ECharts from "@/components/ECharts"; +import { useTheme } from "@/components/ThemeProvider"; +import type { components } from "@/lib/openapi/schema"; + +interface Props { + data?: components["schemas"]["sqlc.GetApplicationSchoolSplitRow"][]; +} + +export default function ApplicationSchoolsChart({ data }: Props) { + const { theme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + if (!data) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+ { + const p = Array.isArray(params) ? params[0] : params; + const percent = p.percent?.toFixed(1); + + return `${p.marker} ${p.name}: ${p.value} (${percent}%)`; + }, + }, + series: [ + { + type: "pie", + center: ["50%", "55%"], + label: { + show: true, + color: isDark ? "#FFFFFF" : "#000000", + }, + data: data?.map((item) => ({ + value: item.count, + name: item.school, + })), + }, + ], + }} + /> +
+ ); +} diff --git a/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx new file mode 100644 index 00000000..51ba7ca1 --- /dev/null +++ b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx @@ -0,0 +1,3 @@ +export default function AttendeeOverview() { + return
Attendee Overview Component
; +} diff --git a/apps/web/src/features/EventOverview/components/StaffOverview.tsx b/apps/web/src/features/EventOverview/components/StaffOverview.tsx new file mode 100644 index 00000000..a61ab85b --- /dev/null +++ b/apps/web/src/features/EventOverview/components/StaffOverview.tsx @@ -0,0 +1,85 @@ +import { Heading } from "react-aria-components"; +import { useApplicationStatistics } from "../hooks/useApplicationStatistics"; +import { Card } from "@/components/ui/Card"; +import ApplicationGenderChart from "./ApplicationGenderChart"; +import ApplicationAgeChart from "./ApplicationAgeChart"; +import ApplicationRaceChart from "./ApplicationRaceChart"; +import ApplicationMajorsChart from "./ApplicationMajorsChart"; +import ApplicationSchoolsChart from "./ApplicationSchoolsChart"; + +interface Props { + eventId: string; +} + +export default function StaffOverview({ eventId }: Props) { + const { data, isLoading, isError, error } = useApplicationStatistics(eventId); + + if (isError && error) { + return ( +
+ + Overview + + +

Error loading application statistics: {error.message}

+
+
+ ); + } + + if (isLoading) { + return ( +
+ + Overview + + + {/* Skeleton Cards */} +
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + return ( +
+ + Overview +

+ Pssst, we look better on Laptops/Desktops... +

+
+ +
+ {/* Row 1: small charts */} +
+ +
+
+ +
+
+ +
+ + {/* Row 2 & 3: Majors chart spans 2 columns and 2 rows on md+ */} +
+ +
+ + {/* Row 2 & 3: Schools chart spans 2 columns and 2 rows on md+ */} +
+ +
+
+
+ ); +} diff --git a/apps/web/src/features/EventOverview/hooks/useApplicationStatistics.ts b/apps/web/src/features/EventOverview/hooks/useApplicationStatistics.ts new file mode 100644 index 00000000..1ae217c4 --- /dev/null +++ b/apps/web/src/features/EventOverview/hooks/useApplicationStatistics.ts @@ -0,0 +1,24 @@ +import { api } from "@/lib/ky"; +import type { paths } from "@/lib/openapi/schema"; +import { useQuery } from "@tanstack/react-query"; + +type ApplicationStatistics = + paths["/events/{eventId}/application/stats"]["get"]["responses"]["200"]["content"]["application/json"]; + +const fetchApplicationStatistics = async (eventId: string) => { + const response = await api + .get(`events/${eventId}/application/stats`) + .json(); + + console.log("Fetched application statistics:", response); + + return response; +}; + +export const useApplicationStatistics = (eventId: string) => { + return useQuery({ + queryKey: ["applicationStatistics", eventId], + queryFn: () => fetchApplicationStatistics(eventId), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; diff --git a/apps/web/src/features/FormBuilder/build.tsx b/apps/web/src/features/FormBuilder/build.tsx index cca3e83a..8e657646 100644 --- a/apps/web/src/features/FormBuilder/build.tsx +++ b/apps/web/src/features/FormBuilder/build.tsx @@ -521,20 +521,28 @@ function useJSONData( if (typeof item.options === "object" && "data" in item.options) { const data = item.options.data; - if (data === "schools") { - fetch(`/assets/schools.json`) - .then((res) => res.json()) - .then((schools) => { - setData( - schools.map((item: string) => ({ id: item, name: item })), - ); - }); - } else if (data === "majors") { - fetch(`/assets/majors.json`) - .then((res) => res.json()) - .then((majors) => { - setData(majors.map((item: string) => ({ id: item, name: item }))); - }); + switch (data) { + case "schools": + const schoolsRes = await fetch(`/assets/schools.json`); + const schools = await schoolsRes.json(); + setData( + schools.map((item: string) => ({ id: item, name: item })), + ); + break; + case "majors": + const majorsRes = await fetch(`/assets/majors.json`); + const majors = await majorsRes.json(); + setData(majors.map((item: string) => ({ id: item, name: item }))); + break; + case "countries": + const countriesRes = await fetch(`/assets/countries.json`); + const countries = await countriesRes.json(); + setData( + countries.map(({ name, code }: { name: string, code: string }) => ({ id: code, name })), + ); + break; + default: + break; } } } diff --git a/apps/web/src/features/FormBuilder/questions/select.ts b/apps/web/src/features/FormBuilder/questions/select.ts index 3a5d2ff5..2981bc63 100644 --- a/apps/web/src/features/FormBuilder/questions/select.ts +++ b/apps/web/src/features/FormBuilder/questions/select.ts @@ -32,7 +32,7 @@ export const SelectQuestion = createQuestionItem({ z .object({ data: z.enum( - ["schools", "majors", "minors", "year"], + ["schools", "majors", "minors", "year", "countries"], "Invalid data option for select question.", ), min: z.number().optional(), diff --git a/apps/web/src/features/FormBuilder/scripts/countries.json b/apps/web/src/features/FormBuilder/scripts/countries.json new file mode 100644 index 00000000..6ca635bb --- /dev/null +++ b/apps/web/src/features/FormBuilder/scripts/countries.json @@ -0,0 +1,245 @@ +[ + {"name": "Afghanistan", "code": "AF"}, + {"name": "Åland Islands", "code": "AX"}, + {"name": "Albania", "code": "AL"}, + {"name": "Algeria", "code": "DZ"}, + {"name": "American Samoa", "code": "AS"}, + {"name": "AndorrA", "code": "AD"}, + {"name": "Angola", "code": "AO"}, + {"name": "Anguilla", "code": "AI"}, + {"name": "Antarctica", "code": "AQ"}, + {"name": "Antigua and Barbuda", "code": "AG"}, + {"name": "Argentina", "code": "AR"}, + {"name": "Armenia", "code": "AM"}, + {"name": "Aruba", "code": "AW"}, + {"name": "Australia", "code": "AU"}, + {"name": "Austria", "code": "AT"}, + {"name": "Azerbaijan", "code": "AZ"}, + {"name": "Bahamas", "code": "BS"}, + {"name": "Bahrain", "code": "BH"}, + {"name": "Bangladesh", "code": "BD"}, + {"name": "Barbados", "code": "BB"}, + {"name": "Belarus", "code": "BY"}, + {"name": "Belgium", "code": "BE"}, + {"name": "Belize", "code": "BZ"}, + {"name": "Benin", "code": "BJ"}, + {"name": "Bermuda", "code": "BM"}, + {"name": "Bhutan", "code": "BT"}, + {"name": "Bolivia", "code": "BO"}, + {"name": "Bosnia and Herzegovina", "code": "BA"}, + {"name": "Botswana", "code": "BW"}, + {"name": "Bouvet Island", "code": "BV"}, + {"name": "Brazil", "code": "BR"}, + {"name": "British Indian Ocean Territory", "code": "IO"}, + {"name": "Brunei Darussalam", "code": "BN"}, + {"name": "Bulgaria", "code": "BG"}, + {"name": "Burkina Faso", "code": "BF"}, + {"name": "Burundi", "code": "BI"}, + {"name": "Cambodia", "code": "KH"}, + {"name": "Cameroon", "code": "CM"}, + {"name": "Canada", "code": "CA"}, + {"name": "Cape Verde", "code": "CV"}, + {"name": "Cayman Islands", "code": "KY"}, + {"name": "Central African Republic", "code": "CF"}, + {"name": "Chad", "code": "TD"}, + {"name": "Chile", "code": "CL"}, + {"name": "China", "code": "CN"}, + {"name": "Christmas Island", "code": "CX"}, + {"name": "Cocos (Keeling) Islands", "code": "CC"}, + {"name": "Colombia", "code": "CO"}, + {"name": "Comoros", "code": "KM"}, + {"name": "Congo", "code": "CG"}, + {"name": "Congo, The Democratic Republic of the", "code": "CD"}, + {"name": "Cook Islands", "code": "CK"}, + {"name": "Costa Rica", "code": "CR"}, + {"name": "Cote D\"Ivoire", "code": "CI"}, + {"name": "Croatia", "code": "HR"}, + {"name": "Cuba", "code": "CU"}, + {"name": "Cyprus", "code": "CY"}, + {"name": "Czech Republic", "code": "CZ"}, + {"name": "Denmark", "code": "DK"}, + {"name": "Djibouti", "code": "DJ"}, + {"name": "Dominica", "code": "DM"}, + {"name": "Dominican Republic", "code": "DO"}, + {"name": "Ecuador", "code": "EC"}, + {"name": "Egypt", "code": "EG"}, + {"name": "El Salvador", "code": "SV"}, + {"name": "Equatorial Guinea", "code": "GQ"}, + {"name": "Eritrea", "code": "ER"}, + {"name": "Estonia", "code": "EE"}, + {"name": "Ethiopia", "code": "ET"}, + {"name": "Falkland Islands (Malvinas)", "code": "FK"}, + {"name": "Faroe Islands", "code": "FO"}, + {"name": "Fiji", "code": "FJ"}, + {"name": "Finland", "code": "FI"}, + {"name": "France", "code": "FR"}, + {"name": "French Guiana", "code": "GF"}, + {"name": "French Polynesia", "code": "PF"}, + {"name": "French Southern Territories", "code": "TF"}, + {"name": "Gabon", "code": "GA"}, + {"name": "Gambia", "code": "GM"}, + {"name": "Georgia", "code": "GE"}, + {"name": "Germany", "code": "DE"}, + {"name": "Ghana", "code": "GH"}, + {"name": "Gibraltar", "code": "GI"}, + {"name": "Greece", "code": "GR"}, + {"name": "Greenland", "code": "GL"}, + {"name": "Grenada", "code": "GD"}, + {"name": "Guadeloupe", "code": "GP"}, + {"name": "Guam", "code": "GU"}, + {"name": "Guatemala", "code": "GT"}, + {"name": "Guernsey", "code": "GG"}, + {"name": "Guinea", "code": "GN"}, + {"name": "Guinea-Bissau", "code": "GW"}, + {"name": "Guyana", "code": "GY"}, + {"name": "Haiti", "code": "HT"}, + {"name": "Heard Island and Mcdonald Islands", "code": "HM"}, + {"name": "Holy See (Vatican City State)", "code": "VA"}, + {"name": "Honduras", "code": "HN"}, + {"name": "Hong Kong", "code": "HK"}, + {"name": "Hungary", "code": "HU"}, + {"name": "Iceland", "code": "IS"}, + {"name": "India", "code": "IN"}, + {"name": "Indonesia", "code": "ID"}, + {"name": "Iran, Islamic Republic Of", "code": "IR"}, + {"name": "Iraq", "code": "IQ"}, + {"name": "Ireland", "code": "IE"}, + {"name": "Isle of Man", "code": "IM"}, + {"name": "Israel", "code": "IL"}, + {"name": "Italy", "code": "IT"}, + {"name": "Jamaica", "code": "JM"}, + {"name": "Japan", "code": "JP"}, + {"name": "Jersey", "code": "JE"}, + {"name": "Jordan", "code": "JO"}, + {"name": "Kazakhstan", "code": "KZ"}, + {"name": "Kenya", "code": "KE"}, + {"name": "Kiribati", "code": "KI"}, + {"name": "Korea, Democratic People\"S Republic of", "code": "KP"}, + {"name": "Korea, Republic of", "code": "KR"}, + {"name": "Kuwait", "code": "KW"}, + {"name": "Kyrgyzstan", "code": "KG"}, + {"name": "Lao People\"S Democratic Republic", "code": "LA"}, + {"name": "Latvia", "code": "LV"}, + {"name": "Lebanon", "code": "LB"}, + {"name": "Lesotho", "code": "LS"}, + {"name": "Liberia", "code": "LR"}, + {"name": "Libyan Arab Jamahiriya", "code": "LY"}, + {"name": "Liechtenstein", "code": "LI"}, + {"name": "Lithuania", "code": "LT"}, + {"name": "Luxembourg", "code": "LU"}, + {"name": "Macao", "code": "MO"}, + {"name": "Macedonia, The Former Yugoslav Republic of", "code": "MK"}, + {"name": "Madagascar", "code": "MG"}, + {"name": "Malawi", "code": "MW"}, + {"name": "Malaysia", "code": "MY"}, + {"name": "Maldives", "code": "MV"}, + {"name": "Mali", "code": "ML"}, + {"name": "Malta", "code": "MT"}, + {"name": "Marshall Islands", "code": "MH"}, + {"name": "Martinique", "code": "MQ"}, + {"name": "Mauritania", "code": "MR"}, + {"name": "Mauritius", "code": "MU"}, + {"name": "Mayotte", "code": "YT"}, + {"name": "Mexico", "code": "MX"}, + {"name": "Micronesia, Federated States of", "code": "FM"}, + {"name": "Moldova, Republic of", "code": "MD"}, + {"name": "Monaco", "code": "MC"}, + {"name": "Mongolia", "code": "MN"}, + {"name": "Montserrat", "code": "MS"}, + {"name": "Morocco", "code": "MA"}, + {"name": "Mozambique", "code": "MZ"}, + {"name": "Myanmar", "code": "MM"}, + {"name": "Namibia", "code": "NA"}, + {"name": "Nauru", "code": "NR"}, + {"name": "Nepal", "code": "NP"}, + {"name": "Netherlands", "code": "NL"}, + {"name": "Netherlands Antilles", "code": "AN"}, + {"name": "New Caledonia", "code": "NC"}, + {"name": "New Zealand", "code": "NZ"}, + {"name": "Nicaragua", "code": "NI"}, + {"name": "Niger", "code": "NE"}, + {"name": "Nigeria", "code": "NG"}, + {"name": "Niue", "code": "NU"}, + {"name": "Norfolk Island", "code": "NF"}, + {"name": "Northern Mariana Islands", "code": "MP"}, + {"name": "Norway", "code": "NO"}, + {"name": "Oman", "code": "OM"}, + {"name": "Pakistan", "code": "PK"}, + {"name": "Palau", "code": "PW"}, + {"name": "Palestinian Territory, Occupied", "code": "PS"}, + {"name": "Panama", "code": "PA"}, + {"name": "Papua New Guinea", "code": "PG"}, + {"name": "Paraguay", "code": "PY"}, + {"name": "Peru", "code": "PE"}, + {"name": "Philippines", "code": "PH"}, + {"name": "Pitcairn", "code": "PN"}, + {"name": "Poland", "code": "PL"}, + {"name": "Portugal", "code": "PT"}, + {"name": "Puerto Rico", "code": "PR"}, + {"name": "Qatar", "code": "QA"}, + {"name": "Reunion", "code": "RE"}, + {"name": "Romania", "code": "RO"}, + {"name": "Russian Federation", "code": "RU"}, + {"name": "RWANDA", "code": "RW"}, + {"name": "Saint Helena", "code": "SH"}, + {"name": "Saint Kitts and Nevis", "code": "KN"}, + {"name": "Saint Lucia", "code": "LC"}, + {"name": "Saint Pierre and Miquelon", "code": "PM"}, + {"name": "Saint Vincent and the Grenadines", "code": "VC"}, + {"name": "Samoa", "code": "WS"}, + {"name": "San Marino", "code": "SM"}, + {"name": "Sao Tome and Principe", "code": "ST"}, + {"name": "Saudi Arabia", "code": "SA"}, + {"name": "Senegal", "code": "SN"}, + {"name": "Serbia and Montenegro", "code": "CS"}, + {"name": "Seychelles", "code": "SC"}, + {"name": "Sierra Leone", "code": "SL"}, + {"name": "Singapore", "code": "SG"}, + {"name": "Slovakia", "code": "SK"}, + {"name": "Slovenia", "code": "SI"}, + {"name": "Solomon Islands", "code": "SB"}, + {"name": "Somalia", "code": "SO"}, + {"name": "South Africa", "code": "ZA"}, + {"name": "South Georgia and the South Sandwich Islands", "code": "GS"}, + {"name": "Spain", "code": "ES"}, + {"name": "Sri Lanka", "code": "LK"}, + {"name": "Sudan", "code": "SD"}, + {"name": "Suriname", "code": "SR"}, + {"name": "Svalbard and Jan Mayen", "code": "SJ"}, + {"name": "Swaziland", "code": "SZ"}, + {"name": "Sweden", "code": "SE"}, + {"name": "Switzerland", "code": "CH"}, + {"name": "Syrian Arab Republic", "code": "SY"}, + {"name": "Taiwan, Province of China", "code": "TW"}, + {"name": "Tajikistan", "code": "TJ"}, + {"name": "Tanzania, United Republic of", "code": "TZ"}, + {"name": "Thailand", "code": "TH"}, + {"name": "Timor-Leste", "code": "TL"}, + {"name": "Togo", "code": "TG"}, + {"name": "Tokelau", "code": "TK"}, + {"name": "Tonga", "code": "TO"}, + {"name": "Trinidad and Tobago", "code": "TT"}, + {"name": "Tunisia", "code": "TN"}, + {"name": "Turkey", "code": "TR"}, + {"name": "Turkmenistan", "code": "TM"}, + {"name": "Turks and Caicos Islands", "code": "TC"}, + {"name": "Tuvalu", "code": "TV"}, + {"name": "Uganda", "code": "UG"}, + {"name": "Ukraine", "code": "UA"}, + {"name": "United Arab Emirates", "code": "AE"}, + {"name": "United Kingdom", "code": "GB"}, + {"name": "United States", "code": "US"}, + {"name": "United States Minor Outlying Islands", "code": "UM"}, + {"name": "Uruguay", "code": "UY"}, + {"name": "Uzbekistan", "code": "UZ"}, + {"name": "Vanuatu", "code": "VU"}, + {"name": "Venezuela", "code": "VE"}, + {"name": "Viet Nam", "code": "VN"}, + {"name": "Virgin Islands, British", "code": "VG"}, + {"name": "Virgin Islands, U.S.", "code": "VI"}, + {"name": "Wallis and Futuna", "code": "WF"}, + {"name": "Western Sahara", "code": "EH"}, + {"name": "Yemen", "code": "YE"}, + {"name": "Zambia", "code": "ZM"}, + {"name": "Zimbabwe", "code": "ZW"} +] \ No newline at end of file diff --git a/apps/web/src/forms/application.json b/apps/web/src/forms/application.json index 050605fb..37845247 100644 --- a/apps/web/src/forms/application.json +++ b/apps/web/src/forms/application.json @@ -91,6 +91,17 @@ } ] }, + { + "name": "country", + "questionType": "select", + "placeholder": "Select your country", + "label": "Country", + "required": true, + "searchable": true, + "options": { + "data": "countries" + } + }, { "type": "layout", "content": [ @@ -99,6 +110,7 @@ "questionType": "select", "placeholder": "Select your gender", "label": "Gender", + "required": true, "hasOther": true, "options": [ { @@ -126,6 +138,7 @@ { "name": "pronouns", "questionType": "select", + "required": true, "placeholder": "Select", "label": "Pronouns", "options": [ @@ -164,6 +177,7 @@ { "name": "race", "questionType": "select", + "required": true, "placeholder": "Select your race", "label": "Race/Ethnicity", "hasOther": true, @@ -209,6 +223,7 @@ { "name": "sexualOrientation", "questionType": "select", + "required": true, "placeholder": "Select", "label": "Do you consider yourself to be any of the following?", "options": [ diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 94518946..0f2d9998 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -187,7 +187,7 @@ export interface paths { content: { "application/json": | Record - | components["schemas"]["handlers.QueueEmailRequest"]; + | components["schemas"]["handlers.QueueTextEmailRequest"]; }; }; responses: { @@ -591,6 +591,63 @@ export interface paths { patch?: never; trace?: never; }; + "/events/{eventId}/application/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Gets an event's submitted application statistics + * @description This aggregates applications by race, gender, age, majors, and schools. This route is only available to event staff and admins. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["services.ApplicationStatistics"]; + }; + }; + /** @description Bad request/Malformed request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Server Error: error getting statistics */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/events/{eventId}/application/submit": { parameters: { query?: never; @@ -1467,10 +1524,10 @@ export interface components { role: components["schemas"]["sqlc.EventRoleType"]; user_id: string; }; - "handlers.QueueEmailRequest": { + "handlers.QueueTextEmailRequest": { body: string; - from: string; - to: string; + subject: string; + to: string[]; }; "handlers.UpdateEmailConsentRequest": { email_consent: boolean; @@ -1523,12 +1580,21 @@ export interface components { error: string; message: string; }; + "services.ApplicationStatistics": { + age_stats: components["schemas"]["sqlc.GetApplicationAgeSplitRow"]; + gender_stats: components["schemas"]["sqlc.GetApplicationGenderSplitRow"]; + major_stats: components["schemas"]["sqlc.GetApplicationMajorSplitRow"][]; + race_stats: components["schemas"]["sqlc.GetApplicationRaceSplitRow"][]; + school_stats: components["schemas"]["sqlc.GetApplicationSchoolSplitRow"][]; + status_stats: components["schemas"]["sqlc.GetApplicationStatusSplitRow"]; + }; "sqlc.Application": { application: number[]; created_at: string; event_id: string; saved_at: string; status: components["schemas"]["sqlc.NullApplicationStatus"]; + submitted_at: string; updated_at: string; user_id: string; }; @@ -1580,6 +1646,42 @@ export interface components { }; /** @enum {string} */ "sqlc.EventRoleType": "admin" | "staff" | "attendee" | "applicant"; + "sqlc.GetApplicationAgeSplitRow": { + age_18: number; + age_19: number; + age_20: number; + age_21: number; + age_22: number; + age_23_plus: number; + underage: number; + }; + "sqlc.GetApplicationGenderSplitRow": { + female: number; + male: number; + non_binary: number; + other: number; + }; + "sqlc.GetApplicationMajorSplitRow": { + count: number; + major: string; + }; + "sqlc.GetApplicationRaceSplitRow": { + count: number; + race_group: string; + }; + "sqlc.GetApplicationSchoolSplitRow": { + count: number; + school: string; + }; + "sqlc.GetApplicationStatusSplitRow": { + accepted: number; + rejected: number; + started: number; + submitted: number; + under_review: number; + waitlisted: number; + withdrawn: number; + }; "sqlc.GetEventStaffRow": { created_at: string; email: string; diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx index 09d2aed0..dbf05947 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/index.tsx @@ -1,4 +1,5 @@ -import { PageUnderConstruction } from "@/components/PageUnderConstruction"; +import AttendeeOverview from "@/features/EventOverview/components/AttendeeOverview"; +import StaffOverview from "@/features/EventOverview/components/StaffOverview"; import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/_protected/events/$eventId/dashboard/")({ @@ -16,5 +17,24 @@ export const Route = createFileRoute("/_protected/events/$eventId/dashboard/")({ }); function RouteComponent() { - return ; + const { eventRole } = Route.useRouteContext(); + const { eventId } = Route.useParams() + + // Both staff and admin are technically "staff" for dashboard purposes + const isStaff = eventRole === "staff" || eventRole === "admin"; + + if (isStaff) { + return ; + } + + if (eventRole === "attendee") { + return ; + } + + // Should never reach here due to the redirect in beforeLoad + return ( +
+

Are you sure you're supposed to be here?

+
+ ); }