feat: add multi-select subject filtering with searchable comboboxes

This commit is contained in:
2026-01-29 02:51:49 -06:00
parent ed72ac6bff
commit 0da2e810fe
12 changed files with 987 additions and 343 deletions
+29 -3
View File
@@ -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<i32>,
course_number_high: Option<i32>,
@@ -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<Vec<(String, String, i64)>> {
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<Vec<String>> {
let rows: Vec<(String,)> =
+22 -11
View File
@@ -298,10 +298,16 @@ async fn metrics() -> Json<Value> {
// Course search & detail API
// ============================================================
#[derive(Deserialize)]
struct SubjectsParams {
term: String,
}
#[derive(Deserialize)]
struct SearchParams {
term: String,
subject: Option<String>,
#[serde(default)]
subject: Vec<String>,
q: Option<String>,
course_number_low: Option<i32>,
course_number_high: Option<i32>,
@@ -484,7 +490,7 @@ async fn build_course_response(
/// `GET /api/courses/search`
async fn search_courses(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
axum_extra::extract::Query(params): axum_extra::extract::Query<SearchParams>,
) -> Result<Json<SearchResponse>, (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,
&params.term,
params.subject.as_deref(),
if params.subject.is_empty() { None } else { Some(&params.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<AppState>,
Query(params): Query<SubjectsParams>,
) -> Result<Json<Vec<CodeDescription>>, (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, &params.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<CodeDescription> = entries
let subjects: Vec<CodeDescription> = 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))