From 0da2e810fe7984eb54ed516929743c4ff03b6898 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 02:51:49 -0600 Subject: [PATCH] feat: add multi-select subject filtering with searchable comboboxes --- Cargo.lock | 69 +- Cargo.toml | 1 + src/data/courses.rs | 32 +- src/web/routes.rs | 33 +- web/src/lib/api.test.ts | 2 +- web/src/lib/api.ts | 8 +- web/src/lib/components/CourseTable.svelte | 767 +++++++++++------- web/src/lib/components/SearchFilters.svelte | 33 +- web/src/lib/components/SubjectCombobox.svelte | 161 ++++ web/src/lib/components/TermCombobox.svelte | 136 ++++ web/src/routes/+page.svelte | 29 +- web/src/routes/layout.css | 59 ++ 12 files changed, 987 insertions(+), 343 deletions(-) create mode 100644 web/src/lib/components/SubjectCombobox.svelte create mode 100644 web/src/lib/components/TermCombobox.svelte diff --git a/Cargo.lock b/Cargo.lock index 24d40f6..742a210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,9 +149,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "bytes", @@ -168,8 +168,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -183,9 +182,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -194,13 +193,37 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "form_urlencoded", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -223,6 +246,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-extra", "bitflags 2.9.4", "chrono", "clap", @@ -2688,9 +2712,19 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] @@ -2706,15 +2740,28 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn 2.0.106", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.143" diff --git a/Cargo.toml b/Cargo.toml index 161f144..b50c2e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ yansi = "1.0.1" extension-traits = "2" ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] } html-escape = "0.2.13" +axum-extra = { version = "0.12.5", features = ["query"] } [dev-dependencies] diff --git a/src/data/courses.rs b/src/data/courses.rs index 7183b51..52276f1 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -12,7 +12,7 @@ use sqlx::PgPool; pub async fn search_courses( db_pool: &PgPool, term_code: &str, - subject: Option<&str>, + subject: Option<&[String]>, title_query: Option<&str>, course_number_low: Option, course_number_high: Option, @@ -34,7 +34,7 @@ pub async fn search_courses( SELECT * FROM courses WHERE term_code = $1 - AND ($2::text IS NULL OR subject = $2) + AND ($2::text[] IS NULL OR subject = ANY($2)) AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%') AND ($4::int IS NULL OR course_number::int >= $4) AND ($5::int IS NULL OR course_number::int <= $5) @@ -65,7 +65,7 @@ pub async fn search_courses( SELECT COUNT(*) FROM courses WHERE term_code = $1 - AND ($2::text IS NULL OR subject = $2) + AND ($2::text[] IS NULL OR subject = ANY($2)) AND ($3::text IS NULL OR title_search @@ plainto_tsquery('simple', $3) OR title ILIKE '%' || $3 || '%') AND ($4::int IS NULL OR course_number::int >= $4) AND ($5::int IS NULL OR course_number::int <= $5) @@ -143,6 +143,32 @@ pub async fn get_course_instructors( Ok(rows) } +/// Get subjects for a term, sorted by total enrollment (descending). +/// +/// Returns only subjects that have courses in the given term, with their +/// descriptions from reference_data and enrollment totals for ranking. +pub async fn get_subjects_by_enrollment( + db_pool: &PgPool, + term_code: &str, +) -> Result> { + let rows: Vec<(String, String, i64)> = sqlx::query_as( + r#" + SELECT c.subject, + COALESCE(rd.description, c.subject), + COALESCE(SUM(c.enrollment), 0) as total_enrollment + FROM courses c + LEFT JOIN reference_data rd ON rd.category = 'subject' AND rd.code = c.subject + WHERE c.term_code = $1 + GROUP BY c.subject, rd.description + ORDER BY total_enrollment DESC + "#, + ) + .bind(term_code) + .fetch_all(db_pool) + .await?; + Ok(rows) +} + /// Get all distinct term codes that have courses in the DB. pub async fn get_available_terms(db_pool: &PgPool) -> Result> { let rows: Vec<(String,)> = diff --git a/src/web/routes.rs b/src/web/routes.rs index 649b066..a329325 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -298,10 +298,16 @@ async fn metrics() -> Json { // Course search & detail API // ============================================================ +#[derive(Deserialize)] +struct SubjectsParams { + term: String, +} + #[derive(Deserialize)] struct SearchParams { term: String, - subject: Option, + #[serde(default)] + subject: Vec, q: Option, course_number_low: Option, course_number_high: Option, @@ -484,7 +490,7 @@ async fn build_course_response( /// `GET /api/courses/search` async fn search_courses( State(state): State, - Query(params): Query, + axum_extra::extract::Query(params): axum_extra::extract::Query, ) -> Result, (AxumStatusCode, String)> { let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); @@ -494,7 +500,7 @@ async fn search_courses( let (courses, total_count) = crate::data::courses::search_courses( &state.db_pool, ¶ms.term, - params.subject.as_deref(), + if params.subject.is_empty() { None } else { Some(¶ms.subject) }, params.q.as_deref(), params.course_number_low, params.course_number_high, @@ -575,19 +581,24 @@ async fn get_terms( Ok(Json(terms)) } -/// `GET /api/subjects?term=202420` +/// `GET /api/subjects?term=202620` async fn get_subjects( State(state): State, + Query(params): Query, ) -> Result>, (AxumStatusCode, String)> { - let cache = state.reference_cache.read().await; - let entries = cache.entries_for_category("subject"); + let rows = crate::data::courses::get_subjects_by_enrollment(&state.db_pool, ¶ms.term) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to get subjects"); + ( + AxumStatusCode::INTERNAL_SERVER_ERROR, + "Failed to get subjects".to_string(), + ) + })?; - let subjects: Vec = entries + let subjects: Vec = rows .into_iter() - .map(|(code, description)| CodeDescription { - code: code.to_string(), - description: description.to_string(), - }) + .map(|(code, description, _enrollment)| CodeDescription { code, description }) .collect(); Ok(Json(subjects)) diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 60f97b8..a68c02a 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -77,7 +77,7 @@ describe("BannerApiClient", () => { const result = await apiClient.searchCourses({ term: "202420", - subject: "CS", + subjects: ["CS"], q: "data", open_only: true, limit: 25, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b805b3b..14cf79f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -49,7 +49,7 @@ export type SortDirection = "asc" | "desc"; export interface SearchParams { term: string; - subject?: string; + subjects?: string[]; q?: string; open_only?: boolean; limit?: number; @@ -92,7 +92,11 @@ export class BannerApiClient { async searchCourses(params: SearchParams): Promise { const query = new URLSearchParams(); query.set("term", params.term); - if (params.subject) query.set("subject", params.subject); + if (params.subjects) { + for (const s of params.subjects) { + query.append("subject", s); + } + } if (params.q) query.set("q", params.q); if (params.open_only) query.set("open_only", "true"); if (params.limit !== undefined) query.set("limit", String(params.limit)); diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index 12d3bfa..5386167 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -10,7 +10,7 @@ import { isTimeTBA, } from "$lib/course"; import CourseDetail from "./CourseDetail.svelte"; -import { slide } from "svelte/transition"; +import { fade, fly, slide } from "svelte/transition"; import { onMount } from "svelte"; import { OverlayScrollbars } from "overlayscrollbars"; import { themeStore } from "$lib/stores/theme.svelte"; @@ -24,9 +24,8 @@ import { type Updater, } from "@tanstack/table-core"; import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte"; -import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui"; +import { DropdownMenu, ContextMenu } from "bits-ui"; import SimpleTooltip from "./SimpleTooltip.svelte"; -import { fade, fly } from "svelte/transition"; let { courses, @@ -46,6 +45,8 @@ let { let expandedCrn: string | null = $state(null); let tableWrapper: HTMLDivElement = undefined!; +let copiedCrn: string | null = $state(null); +let copyTimeoutId: number | undefined; onMount(() => { const osInstance = OverlayScrollbars(tableWrapper, { @@ -76,10 +77,8 @@ onMount(() => { // Column visibility state let columnVisibility: VisibilityState = $state({}); -const DEFAULT_VISIBILITY: VisibilityState = {}; - function resetColumnVisibility() { - columnVisibility = { ...DEFAULT_VISIBILITY }; + columnVisibility = {}; } function handleVisibilityChange(updater: Updater) { @@ -93,6 +92,33 @@ function toggleRow(crn: string) { expandedCrn = expandedCrn === crn ? null : crn; } +async function handleCopyCrn(event: MouseEvent | KeyboardEvent, crn: string) { + event.stopPropagation(); + + try { + await navigator.clipboard.writeText(crn); + + if (copyTimeoutId !== undefined) { + clearTimeout(copyTimeoutId); + } + + copiedCrn = crn; + copyTimeoutId = window.setTimeout(() => { + copiedCrn = null; + copyTimeoutId = undefined; + }, 1000); + } catch (err) { + console.error("Failed to copy CRN:", err); + } +} + +function handleCrnKeydown(event: KeyboardEvent, crn: string) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleCopyCrn(event, crn); + } +} + function openSeats(course: CourseResponse): number { return Math.max(0, course.maxEnrollment - course.enrollment); } @@ -224,290 +250,475 @@ const table = createSvelteTable({ {#snippet columnVisibilityItems(variant: "dropdown" | "context")} - {#if variant === "dropdown"} - - - Toggle columns - - {#each columns as col} - {@const id = col.id!} - {@const label = typeof col.header === "string" ? col.header : id} - { - columnVisibility = { ...columnVisibility, [id]: checked }; - }} - class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground" - > - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {label} - {/snippet} - - {/each} - - {#if hasCustomVisibility} - - - - Reset to default - + {#if variant === "dropdown"} + + + Toggle columns + + {#each columns as col} + {@const id = col.id!} + {@const label = + typeof col.header === "string" ? col.header : id} + { + columnVisibility = { + ...columnVisibility, + [id]: checked, + }; + }} + class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {label} + {/snippet} + + {/each} + + {#if hasCustomVisibility} + + + + Reset to default + + {/if} + {:else} + + + Toggle columns + + {#each columns as col} + {@const id = col.id!} + {@const label = + typeof col.header === "string" ? col.header : id} + { + columnVisibility = { + ...columnVisibility, + [id]: checked, + }; + }} + class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {label} + {/snippet} + + {/each} + + {#if hasCustomVisibility} + + + + Reset to default + + {/if} {/if} - {:else} - - - Toggle columns - - {#each columns as col} - {@const id = col.id!} - {@const label = typeof col.header === "string" ? col.header : id} - { - columnVisibility = { ...columnVisibility, [id]: checked }; - }} - class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground" - > - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {label} - {/snippet} - - {/each} - - {#if hasCustomVisibility} - - - - Reset to default - - {/if} - {/if} {/snippet}
- - - + - - View + + View - - - Show or hide table columns - - - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- {@render columnVisibilityItems("dropdown")} -
-
- {/if} - {/snippet} -
-
-
+ + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ {@render columnVisibilityItems("dropdown")} +
+
+ {/if} + {/snippet} +
+
+
- - - - - {#each table.getHeaderGroups() as headerGroup} - - {#each headerGroup.headers as header} - {#if header.column.getIsVisible()} -
- {#if header.column.getCanSort()} - - {#if typeof header.column.columnDef.header === "string"} - {header.column.columnDef.header} - {:else} - - {/if} - {#if header.column.getIsSorted() === "asc"} - - {:else if header.column.getIsSorted() === "desc"} - - {:else} - - {/if} - - {:else if typeof header.column.columnDef.header === "string"} - {header.column.columnDef.header} + + + + + {#each table.getHeaderGroups() as headerGroup} + + {#each headerGroup.headers as header} + {#if header.column.getIsVisible()} + + {/if} + {/each} + + {/each} + + + {#if loading && courses.length === 0} + {#each Array(5) as _} + + {#each table.getVisibleLeafColumns() as col} + + {/each} + + {/each} + {:else if courses.length === 0} + + + {:else} - + {#each table.getRowModel().rows as row (row.id)} + {@const course = row.original} + toggleRow(course.crn)} + > + {#each row.getVisibleCells() as cell (cell.id)} + {@const colId = cell.column.id} + {#if colId === "crn"} + + {:else if colId === "course_code"} + {@const subjectDesc = + subjectMap[course.subject]} + + {:else if colId === "title"} + + {:else if colId === "instructor"} + {@const primary = getPrimaryInstructor( + course.instructors, + )} + + {:else if colId === "time"} + + {:else if colId === "location"} + + {:else if colId === "seats"} + + {/if} + {/each} + + {#if expandedCrn === course.crn} + + + + {/if} + {/each} {/if} - - {/if} - {/each} - - {/each} - - - {#if loading && courses.length === 0} - {#each Array(5) as _} - - {#each table.getVisibleLeafColumns() as col} - - {/each} - - {/each} - {:else if courses.length === 0} - - - - {:else} - {#each table.getRowModel().rows as row (row.id)} - {@const course = row.original} - toggleRow(course.crn)} - > - {#each row.getVisibleCells() as cell (cell.id)} - {@const colId = cell.column.id} - {#if colId === "crn"} - - {:else if colId === "course_code"} - {@const subjectDesc = subjectMap[course.subject]} - - {:else if colId === "title"} - - {:else if colId === "instructor"} - {@const primary = getPrimaryInstructor(course.instructors)} - - {:else if colId === "time"} - - {:else if colId === "location"} - - {:else if colId === "seats"} - - {/if} - {/each} - - {#if expandedCrn === course.crn} - - - - {/if} - {/each} - {/if} - -
+ {#if header.column.getCanSort()} + + {#if typeof header.column.columnDef.header === "string"} + {header.column.columnDef + .header} + {:else} + + {/if} + {#if header.column.getIsSorted() === "asc"} + + {:else if header.column.getIsSorted() === "desc"} + + {:else} + + {/if} + + {:else if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} +
+
+
+ No courses found. Try adjusting your filters. +
+ + + + {course.subject} + {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} + + + + {course.title} + + + + {primaryInstructorDisplay( + course, + )} + + {#if primaryRating(course)} + {@const r = + primaryRating(course)!} + + {r.rating.toFixed( + 1, + )}★ + + {/if} + + {#if timeIsTBA(course)} + TBA + {:else} + {@const mt = + course.meetingTimes[0]} + {#if !isMeetingTimeTBA(mt)} + {formatMeetingDays( + mt, + )} + {" "} + {/if} + {#if !isTimeTBA(mt)} + {formatTime( + mt.begin_time, + )}–{formatTime( + mt.end_time, + )} + {:else} + TBA + {/if} + {/if} + + {#if formatLocation(course)} + {formatLocation( + course, + )} + {:else} + + {/if} + + + + + {#if openSeats(course) === 0}Full{:else}{openSeats( + course, + )} open{/if} + {course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} + · WL {course.waitCount}/{course.waitCapacity}{/if} + + +
+
+ +
+
-
-
- No courses found. Try adjusting your filters. -
{course.crn} - - {course.subject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} - - - - {course.title} - - - - {primaryInstructorDisplay(course)} - - {#if primaryRating(course)} - {@const r = primaryRating(course)!} - - {r.rating.toFixed(1)}★ - - {/if} - - {#if timeIsTBA(course)} - TBA - {:else} - {@const mt = course.meetingTimes[0]} - {#if !isMeetingTimeTBA(mt)} - {formatMeetingDays(mt)} - {" "} - {/if} - {#if !isTimeTBA(mt)} - {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} - {:else} - TBA - {/if} - {/if} - - {#if formatLocation(course)} - {formatLocation(course)} - {:else} - - {/if} - - - - - {#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if} - {course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if} - - -
-
- -
-
-
- - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
- {@render columnVisibilityItems("context")} -
-
- {/if} - {/snippet} -
-
-
+ +
+
+ + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ {@render columnVisibilityItems("context")} +
+
+ {/if} + {/snippet} +
+
+
diff --git a/web/src/lib/components/SearchFilters.svelte b/web/src/lib/components/SearchFilters.svelte index 3f121ef..9d15f48 100644 --- a/web/src/lib/components/SearchFilters.svelte +++ b/web/src/lib/components/SearchFilters.svelte @@ -1,53 +1,42 @@ -
- +
+ - + -