From d108a41f915e3e1047d18dcfb9e5c9fe71b587bd Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 00:26:40 -0600 Subject: [PATCH] feat: sync RMP professor ratings and display in course search interface --- .../20260128100000_add_rmp_professors.sql | 17 + src/data/courses.rs | 21 +- src/data/mod.rs | 1 + src/data/rmp.rs | 311 ++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 1 + src/rmp/mod.rs | 161 +++++++++ src/scraper/scheduler.rs | 74 ++++- src/web/routes.rs | 16 +- web/src/lib/components/CourseDetail.svelte | 250 +++++++++++--- web/src/lib/components/CourseTable.svelte | 137 ++++++-- web/src/lib/components/Pagination.svelte | 25 +- web/src/lib/components/SearchFilters.svelte | 32 +- web/src/lib/course.test.ts | 113 ++++++- web/src/lib/course.ts | 70 +++- web/src/routes/+page.svelte | 186 +++++------ web/src/routes/health/+page.svelte | 5 +- 17 files changed, 1173 insertions(+), 248 deletions(-) create mode 100644 migrations/20260128100000_add_rmp_professors.sql create mode 100644 src/data/rmp.rs create mode 100644 src/rmp/mod.rs diff --git a/migrations/20260128100000_add_rmp_professors.sql b/migrations/20260128100000_add_rmp_professors.sql new file mode 100644 index 0000000..9234412 --- /dev/null +++ b/migrations/20260128100000_add_rmp_professors.sql @@ -0,0 +1,17 @@ +-- RMP professor data (bulk synced from RateMyProfessors) +CREATE TABLE rmp_professors ( + legacy_id INTEGER PRIMARY KEY, + graphql_id VARCHAR NOT NULL, + first_name VARCHAR NOT NULL, + last_name VARCHAR NOT NULL, + department VARCHAR, + avg_rating REAL, + avg_difficulty REAL, + num_ratings INTEGER NOT NULL DEFAULT 0, + would_take_again_pct REAL, + last_synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Link Banner instructors to RMP professors +ALTER TABLE instructors ADD COLUMN rmp_legacy_id INTEGER REFERENCES rmp_professors(legacy_id); +ALTER TABLE instructors ADD COLUMN rmp_match_status VARCHAR NOT NULL DEFAULT 'pending'; diff --git a/src/data/courses.rs b/src/data/courses.rs index 7b7b38e..1aeceda 100644 --- a/src/data/courses.rs +++ b/src/data/courses.rs @@ -98,23 +98,26 @@ pub async fn get_course_by_crn( /// Get instructors for a course by course ID. /// -/// Returns `(banner_id, display_name, email, is_primary)` tuples. +/// Returns `(banner_id, display_name, email, is_primary, rmp_avg_rating, rmp_num_ratings)` tuples. pub async fn get_course_instructors( db_pool: &PgPool, course_id: i32, -) -> Result, bool)>> { - let rows: Vec<(String, String, Option, bool)> = sqlx::query_as( - r#" - SELECT i.banner_id, i.display_name, i.email, ci.is_primary +) -> Result, bool, Option, Option)>> { + let rows: Vec<(String, String, Option, bool, Option, Option)> = + sqlx::query_as( + r#" + SELECT i.banner_id, i.display_name, i.email, ci.is_primary, + rp.avg_rating, rp.num_ratings FROM course_instructors ci JOIN instructors i ON i.banner_id = ci.instructor_id + LEFT JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id WHERE ci.course_id = $1 ORDER BY ci.is_primary DESC, i.display_name "#, - ) - .bind(course_id) - .fetch_all(db_pool) - .await?; + ) + .bind(course_id) + .fetch_all(db_pool) + .await?; Ok(rows) } diff --git a/src/data/mod.rs b/src/data/mod.rs index 9f2afda..f97a776 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -4,4 +4,5 @@ pub mod batch; pub mod courses; pub mod models; pub mod reference; +pub mod rmp; pub mod scrape_jobs; diff --git a/src/data/rmp.rs b/src/data/rmp.rs new file mode 100644 index 0000000..e215448 --- /dev/null +++ b/src/data/rmp.rs @@ -0,0 +1,311 @@ +//! Database operations for RateMyProfessors data. + +use crate::error::Result; +use crate::rmp::RmpProfessor; +use sqlx::PgPool; +use std::collections::{HashMap, HashSet}; +use tracing::{debug, info, warn}; + +/// Bulk upsert RMP professors using the UNNEST pattern. +/// +/// Deduplicates by `legacy_id` before inserting — the RMP API can return +/// the same professor on multiple pages. +pub async fn batch_upsert_rmp_professors( + professors: &[RmpProfessor], + db_pool: &PgPool, +) -> Result<()> { + if professors.is_empty() { + return Ok(()); + } + + // Deduplicate: keep last occurrence per legacy_id (latest page wins) + let mut seen = HashSet::new(); + let deduped: Vec<&RmpProfessor> = professors + .iter() + .rev() + .filter(|p| seen.insert(p.legacy_id)) + .collect(); + + let legacy_ids: Vec = deduped.iter().map(|p| p.legacy_id).collect(); + let graphql_ids: Vec<&str> = deduped.iter().map(|p| p.graphql_id.as_str()).collect(); + let first_names: Vec = deduped.iter().map(|p| p.first_name.trim().to_string()).collect(); + let first_name_refs: Vec<&str> = first_names.iter().map(|s| s.as_str()).collect(); + let last_names: Vec = deduped.iter().map(|p| p.last_name.trim().to_string()).collect(); + let last_name_refs: Vec<&str> = last_names.iter().map(|s| s.as_str()).collect(); + let departments: Vec> = deduped + .iter() + .map(|p| p.department.as_deref()) + .collect(); + let avg_ratings: Vec> = deduped.iter().map(|p| p.avg_rating).collect(); + let avg_difficulties: Vec> = deduped.iter().map(|p| p.avg_difficulty).collect(); + let num_ratings: Vec = deduped.iter().map(|p| p.num_ratings).collect(); + let would_take_again_pcts: Vec> = deduped + .iter() + .map(|p| p.would_take_again_pct) + .collect(); + + sqlx::query( + r#" + INSERT INTO rmp_professors ( + legacy_id, graphql_id, first_name, last_name, department, + avg_rating, avg_difficulty, num_ratings, would_take_again_pct, + last_synced_at + ) + SELECT + v.legacy_id, v.graphql_id, v.first_name, v.last_name, v.department, + v.avg_rating, v.avg_difficulty, v.num_ratings, v.would_take_again_pct, + NOW() + FROM UNNEST( + $1::int4[], $2::text[], $3::text[], $4::text[], $5::text[], + $6::real[], $7::real[], $8::int4[], $9::real[] + ) AS v( + legacy_id, graphql_id, first_name, last_name, department, + avg_rating, avg_difficulty, num_ratings, would_take_again_pct + ) + ON CONFLICT (legacy_id) + DO UPDATE SET + graphql_id = EXCLUDED.graphql_id, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + department = EXCLUDED.department, + avg_rating = EXCLUDED.avg_rating, + avg_difficulty = EXCLUDED.avg_difficulty, + num_ratings = EXCLUDED.num_ratings, + would_take_again_pct = EXCLUDED.would_take_again_pct, + last_synced_at = EXCLUDED.last_synced_at + "#, + ) + .bind(&legacy_ids) + .bind(&graphql_ids) + .bind(&first_name_refs) + .bind(&last_name_refs) + .bind(&departments) + .bind(&avg_ratings) + .bind(&avg_difficulties) + .bind(&num_ratings) + .bind(&would_take_again_pcts) + .execute(db_pool) + .await + .map_err(|e| anyhow::anyhow!("Failed to batch upsert RMP professors: {}", e))?; + + Ok(()) +} + +/// Normalize a name for matching: lowercase, trim, strip trailing periods. +fn normalize(s: &str) -> String { + s.trim().to_lowercase().trim_end_matches('.').to_string() +} + +/// Parse Banner's "Last, First Middle" display name into (last, first) tokens. +/// +/// Returns `None` if the format is unparseable (no comma, empty parts). +fn parse_display_name(display_name: &str) -> Option<(String, String)> { + let (last_part, first_part) = display_name.split_once(',')?; + let last = normalize(last_part); + // Take only the first token of the first-name portion to drop middle names/initials. + let first = normalize(first_part.split_whitespace().next()?); + if last.is_empty() || first.is_empty() { + return None; + } + Some((last, first)) +} + +/// Auto-match instructors to RMP professors by normalized name. +/// +/// Loads all pending instructors and all RMP professors, then matches in Rust +/// using normalized name comparison. Only assigns a match when exactly one RMP +/// professor matches a given instructor. +pub async fn auto_match_instructors(db_pool: &PgPool) -> Result { + // Load pending instructors + let instructors: Vec<(String, String)> = sqlx::query_as( + "SELECT banner_id, display_name FROM instructors WHERE rmp_match_status = 'pending'", + ) + .fetch_all(db_pool) + .await?; + + if instructors.is_empty() { + info!(matched = 0, "No pending instructors to match"); + return Ok(0); + } + + // Load all RMP professors + let professors: Vec<(i32, String, String)> = sqlx::query_as( + "SELECT legacy_id, first_name, last_name FROM rmp_professors", + ) + .fetch_all(db_pool) + .await?; + + // Build a lookup: (normalized_last, normalized_first) -> list of legacy_ids + let mut rmp_index: HashMap<(String, String), Vec> = HashMap::new(); + for (legacy_id, first, last) in &professors { + let key = (normalize(last), normalize(first)); + rmp_index.entry(key).or_default().push(*legacy_id); + } + + // Match each instructor + let mut matches: Vec<(i32, String)> = Vec::new(); // (legacy_id, banner_id) + let mut no_comma = 0u64; + let mut no_match = 0u64; + let mut ambiguous = 0u64; + + for (banner_id, display_name) in &instructors { + let Some((last, first)) = parse_display_name(display_name) else { + no_comma += 1; + continue; + }; + + let key = (last, first); + match rmp_index.get(&key) { + Some(ids) if ids.len() == 1 => { + matches.push((ids[0], banner_id.clone())); + } + Some(ids) => { + ambiguous += 1; + debug!( + banner_id, + display_name, + candidates = ids.len(), + "Ambiguous RMP match, skipping" + ); + } + None => { + no_match += 1; + } + } + } + + if no_comma > 0 || ambiguous > 0 { + warn!( + total_pending = instructors.len(), + no_comma, + no_match, + ambiguous, + matched = matches.len(), + "RMP matching diagnostics" + ); + } + + // Batch update matches + if matches.is_empty() { + info!(matched = 0, "Auto-matched instructors to RMP professors"); + return Ok(0); + } + + let legacy_ids: Vec = matches.iter().map(|(id, _)| *id).collect(); + let banner_ids: Vec<&str> = matches.iter().map(|(_, bid)| bid.as_str()).collect(); + + let result = sqlx::query( + r#" + UPDATE instructors i + SET + rmp_legacy_id = m.legacy_id, + rmp_match_status = 'auto' + FROM UNNEST($1::int4[], $2::text[]) AS m(legacy_id, banner_id) + WHERE i.banner_id = m.banner_id + "#, + ) + .bind(&legacy_ids) + .bind(&banner_ids) + .execute(db_pool) + .await + .map_err(|e| anyhow::anyhow!("Failed to update instructor RMP matches: {}", e))?; + + let matched = result.rows_affected(); + info!(matched, "Auto-matched instructors to RMP professors"); + Ok(matched) +} + +/// Retrieve RMP rating data for an instructor by banner_id. +/// +/// Returns `(avg_rating, num_ratings)` if the instructor has an RMP match. +#[allow(dead_code)] +pub async fn get_instructor_rmp_data( + db_pool: &PgPool, + banner_id: &str, +) -> Result> { + let row: Option<(f32, i32)> = sqlx::query_as( + r#" + SELECT rp.avg_rating, rp.num_ratings + FROM instructors i + JOIN rmp_professors rp ON rp.legacy_id = i.rmp_legacy_id + WHERE i.banner_id = $1 + AND rp.avg_rating IS NOT NULL + "#, + ) + .bind(banner_id) + .fetch_optional(db_pool) + .await?; + Ok(row) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_standard_name() { + assert_eq!( + parse_display_name("Smith, John"), + Some(("smith".into(), "john".into())) + ); + } + + #[test] + fn parse_name_with_middle() { + assert_eq!( + parse_display_name("Smith, John David"), + Some(("smith".into(), "john".into())) + ); + } + + #[test] + fn parse_name_with_middle_initial() { + assert_eq!( + parse_display_name("Garcia, Maria L."), + Some(("garcia".into(), "maria".into())) + ); + } + + #[test] + fn parse_name_with_suffix_in_last() { + // Banner may encode "Jr." as part of the last name. + // normalize() strips trailing periods so "Jr." becomes "jr". + assert_eq!( + parse_display_name("Smith Jr., James"), + Some(("smith jr".into(), "james".into())) + ); + } + + #[test] + fn parse_no_comma_returns_none() { + assert_eq!(parse_display_name("SingleName"), None); + } + + #[test] + fn parse_empty_first_returns_none() { + assert_eq!(parse_display_name("Smith,"), None); + } + + #[test] + fn parse_empty_last_returns_none() { + assert_eq!(parse_display_name(", John"), None); + } + + #[test] + fn parse_extra_whitespace() { + assert_eq!( + parse_display_name(" Doe , Jane Marie "), + Some(("doe".into(), "jane".into())) + ); + } + + #[test] + fn normalize_trims_and_lowercases() { + assert_eq!(normalize(" FOO "), "foo"); + } + + #[test] + fn normalize_strips_trailing_period() { + assert_eq!(normalize("Jr."), "jr"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 840f68d..a5119fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod data; pub mod error; pub mod formatter; pub mod logging; +pub mod rmp; pub mod scraper; pub mod services; pub mod signals; diff --git a/src/main.rs b/src/main.rs index ab176e8..bfc6e27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod data; mod error; mod formatter; mod logging; +mod rmp; mod scraper; mod services; mod signals; diff --git a/src/rmp/mod.rs b/src/rmp/mod.rs new file mode 100644 index 0000000..b5d9e0d --- /dev/null +++ b/src/rmp/mod.rs @@ -0,0 +1,161 @@ +//! RateMyProfessors GraphQL client for bulk professor data sync. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +/// UTSA's school ID on RateMyProfessors (base64 of "School-1516"). +const UTSA_SCHOOL_ID: &str = "U2Nob29sLTE1MTY="; + +/// Basic auth header value (base64 of "test:test"). +const AUTH_HEADER: &str = "Basic dGVzdDp0ZXN0"; + +/// GraphQL endpoint. +const GRAPHQL_URL: &str = "https://www.ratemyprofessors.com/graphql"; + +/// Page size for paginated fetches. +const PAGE_SIZE: u32 = 100; + +/// A professor record from RateMyProfessors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RmpProfessor { + pub legacy_id: i32, + pub graphql_id: String, + pub first_name: String, + pub last_name: String, + pub department: Option, + pub avg_rating: Option, + pub avg_difficulty: Option, + pub num_ratings: i32, + pub would_take_again_pct: Option, +} + +/// Client for fetching professor data from RateMyProfessors. +pub struct RmpClient { + http: reqwest::Client, +} + +impl RmpClient { + pub fn new() -> Self { + Self { + http: reqwest::Client::new(), + } + } + + /// Fetch all professors for UTSA via paginated GraphQL queries. + pub async fn fetch_all_professors(&self) -> Result> { + let mut all = Vec::new(); + let mut cursor: Option = None; + + loop { + let after_clause = match &cursor { + Some(c) => format!(r#", after: "{}""#, c), + None => String::new(), + }; + + let query = format!( + r#"query {{ + newSearch {{ + teachers(query: {{ text: "", schoolID: "{school_id}" }}, first: {page_size}{after}) {{ + edges {{ + cursor + node {{ + id + legacyId + firstName + lastName + department + avgRating + avgDifficulty + numRatings + wouldTakeAgainPercent + }} + }} + pageInfo {{ + hasNextPage + endCursor + }} + }} + }} +}}"#, + school_id = UTSA_SCHOOL_ID, + page_size = PAGE_SIZE, + after = after_clause, + ); + + let body = serde_json::json!({ "query": query }); + + let resp = self + .http + .post(GRAPHQL_URL) + .header("Authorization", AUTH_HEADER) + .json(&body) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("RMP GraphQL request failed ({status}): {text}"); + } + + let json: serde_json::Value = resp.json().await?; + + let teachers = &json["data"]["newSearch"]["teachers"]; + let edges = teachers["edges"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("Missing edges in RMP response"))?; + + for edge in edges { + let node = &edge["node"]; + let wta = node["wouldTakeAgainPercent"] + .as_f64() + .map(|v| v as f32) + .filter(|&v| v >= 0.0); + + all.push(RmpProfessor { + legacy_id: node["legacyId"] + .as_i64() + .ok_or_else(|| anyhow::anyhow!("Missing legacyId"))? + as i32, + graphql_id: node["id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing id"))? + .to_string(), + first_name: node["firstName"] + .as_str() + .unwrap_or_default() + .to_string(), + last_name: node["lastName"] + .as_str() + .unwrap_or_default() + .to_string(), + department: node["department"].as_str().map(|s| s.to_string()), + avg_rating: node["avgRating"].as_f64().map(|v| v as f32), + avg_difficulty: node["avgDifficulty"].as_f64().map(|v| v as f32), + num_ratings: node["numRatings"].as_i64().unwrap_or(0) as i32, + would_take_again_pct: wta, + }); + } + + let page_info = &teachers["pageInfo"]; + let has_next = page_info["hasNextPage"].as_bool().unwrap_or(false); + + if !has_next { + break; + } + + cursor = page_info["endCursor"] + .as_str() + .map(|s| s.to_string()); + + debug!( + fetched = all.len(), + "RMP pagination: fetching next page" + ); + } + + info!(total = all.len(), "Fetched all RMP professors"); + Ok(all) + } +} diff --git a/src/scraper/scheduler.rs b/src/scraper/scheduler.rs index 78ce472..11956de 100644 --- a/src/scraper/scheduler.rs +++ b/src/scraper/scheduler.rs @@ -2,6 +2,7 @@ use crate::banner::{BannerApi, Term}; use crate::data::models::{ReferenceData, ScrapePriority, TargetType}; use crate::data::scrape_jobs; use crate::error::Result; +use crate::rmp::RmpClient; use crate::scraper::jobs::subject::SubjectJob; use crate::state::ReferenceCache; use serde_json::json; @@ -16,6 +17,9 @@ use tracing::{debug, error, info, warn}; /// How often reference data is re-scraped (6 hours). const REFERENCE_DATA_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// How often RMP data is synced (24 hours). +const RMP_SYNC_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + /// Periodically analyzes data and enqueues prioritized scrape jobs. pub struct Scheduler { db_pool: PgPool, @@ -53,6 +57,8 @@ impl Scheduler { let mut current_work: Option<(tokio::task::JoinHandle<()>, CancellationToken)> = None; // Scrape reference data immediately on first cycle let mut last_ref_scrape = Instant::now() - REFERENCE_DATA_INTERVAL; + // Sync RMP data immediately on first cycle + let mut last_rmp_sync = Instant::now() - RMP_SYNC_INTERVAL; loop { tokio::select! { @@ -60,6 +66,7 @@ impl Scheduler { let cancel_token = CancellationToken::new(); let should_scrape_ref = last_ref_scrape.elapsed() >= REFERENCE_DATA_INTERVAL; + let should_sync_rmp = last_rmp_sync.elapsed() >= RMP_SYNC_INTERVAL; // Spawn work in separate task to allow graceful cancellation during shutdown. let work_handle = tokio::spawn({ @@ -68,28 +75,47 @@ impl Scheduler { let cancel_token = cancel_token.clone(); let reference_cache = self.reference_cache.clone(); - async move { - tokio::select! { - _ = async { - if should_scrape_ref - && let Err(e) = Self::scrape_reference_data(&db_pool, &banner_api, &reference_cache).await - { - error!(error = ?e, "Failed to scrape reference data"); + async move { + tokio::select! { + _ = async { + // RMP sync is independent of Banner API — run it + // concurrently with reference data scraping so it + // doesn't wait behind rate-limited Banner calls. + let rmp_fut = async { + if should_sync_rmp + && let Err(e) = Self::sync_rmp_data(&db_pool).await + { + error!(error = ?e, "Failed to sync RMP data"); + } + }; + + let ref_fut = async { + if should_scrape_ref + && let Err(e) = Self::scrape_reference_data(&db_pool, &banner_api, &reference_cache).await + { + error!(error = ?e, "Failed to scrape reference data"); + } + }; + + tokio::join!(rmp_fut, ref_fut); + + if let Err(e) = Self::schedule_jobs_impl(&db_pool, &banner_api).await { + error!(error = ?e, "Failed to schedule jobs"); + } + } => {} + _ = cancel_token.cancelled() => { + debug!("Scheduling work cancelled gracefully"); + } } - if let Err(e) = Self::schedule_jobs_impl(&db_pool, &banner_api).await { - error!(error = ?e, "Failed to schedule jobs"); - } - } => {} - _ = cancel_token.cancelled() => { - debug!("Scheduling work cancelled gracefully"); } - } - } }); if should_scrape_ref { last_ref_scrape = Instant::now(); } + if should_sync_rmp { + last_rmp_sync = Instant::now(); + } current_work = Some((work_handle, cancel_token)); next_run = time::Instant::now() + work_interval; @@ -194,6 +220,24 @@ impl Scheduler { Ok(()) } + /// Fetch all RMP professors, upsert to DB, and auto-match against Banner instructors. + #[tracing::instrument(skip_all)] + async fn sync_rmp_data(db_pool: &PgPool) -> Result<()> { + info!("Starting RMP data sync"); + + let client = RmpClient::new(); + let professors = client.fetch_all_professors().await?; + let total = professors.len(); + + crate::data::rmp::batch_upsert_rmp_professors(&professors, db_pool).await?; + info!(total, "RMP professors upserted"); + + let matched = crate::data::rmp::auto_match_instructors(db_pool).await?; + info!(total, matched, "RMP sync complete"); + + Ok(()) + } + /// Scrape all reference data categories from Banner and upsert to DB, then refresh cache. #[tracing::instrument(skip_all)] async fn scrape_reference_data( diff --git a/src/web/routes.rs b/src/web/routes.rs index 9b596e9..dc98342 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -357,6 +357,8 @@ pub struct InstructorResponse { display_name: String, email: Option, is_primary: bool, + rmp_rating: Option, + rmp_num_ratings: Option, } #[derive(Serialize, TS)] @@ -387,11 +389,15 @@ async fn build_course_response( .unwrap_or_default() .into_iter() .map( - |(banner_id, display_name, email, is_primary)| InstructorResponse { - banner_id, - display_name, - email, - is_primary, + |(banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings)| { + InstructorResponse { + banner_id, + display_name, + email, + is_primary, + rmp_rating, + rmp_num_ratings, + } }, ) .collect(); diff --git a/web/src/lib/components/CourseDetail.svelte b/web/src/lib/components/CourseDetail.svelte index d5b401f..71082b5 100644 --- a/web/src/lib/components/CourseDetail.svelte +++ b/web/src/lib/components/CourseDetail.svelte @@ -1,87 +1,201 @@ -
-
+
+
-

Instructors

+

+ Instructors +

{#if course.instructors.length > 0} -
    +
    {#each course.instructors as instructor} -
  • - {instructor.displayName} - {#if instructor.isPrimary} - primary - {/if} - {#if instructor.email} - — {instructor.email} - {/if} -
  • + + + + {instructor.displayName} + {#if 'rmpRating' in instructor && instructor.rmpRating} + {@const rating = instructor.rmpRating as number} + {rating.toFixed(1)}★ + {/if} + + + +
    +
    {instructor.displayName}
    + {#if instructor.isPrimary} +
    Primary instructor
    + {/if} + {#if 'rmpRating' in instructor && instructor.rmpRating} +
    + {(instructor.rmpRating as number).toFixed(1)}/5 ({(instructor as any).rmpNumRatings ?? 0} ratings) +
    + {/if} + {#if instructor.email} + + {/if} +
    +
    +
    {/each} -
+
{:else} - Staff + Staff {/if}
-

Meeting Times

+

+ Meeting Times +

{#if course.meetingTimes.length > 0} -
    +
      {#each course.meetingTimes as mt} -
    • - {formatMeetingDays(mt) || "TBA"} - {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} - {#if mt.building || mt.room} - - ({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}) - +
    • + {#if isMeetingTimeTBA(mt) && isTimeTBA(mt)} + TBA + {:else} +
      + {#if !isMeetingTimeTBA(mt)} + + {formatMeetingDaysLong(mt)} + + {/if} + {#if !isTimeTBA(mt)} + + {formatTime(mt.begin_time)}–{formatTime(mt.end_time)} + + {:else} + Time TBA + {/if} +
      {/if} -
      {mt.start_date} – {mt.end_date}
      + {#if mt.building || mt.room} +
      + {mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""} +
      + {/if} +
      + {formatDate(mt.start_date)} – {formatDate(mt.end_date)} +
    • {/each}
    {:else} - TBA + TBA {/if}
-

Delivery

- +

+ + Delivery + + + + + + How the course is taught: in-person, online, hybrid, etc. + + + +

+ {course.instructionalMethod ?? "—"} {#if course.campus} - · {course.campus} + · {course.campus} {/if}
-

Credits

- {formatCreditHours(course)} +

+ Credits +

+ {formatCreditHours(course)}
{#if course.attributes.length > 0}
-

Attributes

-
+

+ + Attributes + + + + + + Course flags for degree requirements, core curriculum, or special designations + + + +

+
{#each course.attributes as attr} - - {attr} - + + + + {attr} + + + + Course attribute code + + {/each}
@@ -90,21 +204,53 @@ {#if course.crossList}
-

Cross-list

- - {course.crossList} - {#if course.crossListCount != null && course.crossListCapacity != null} - ({course.crossListCount}/{course.crossListCapacity}) - {/if} - +

+ + Cross-list + + + + + + Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class. + + + +

+ + + + + {course.crossList} + + {#if course.crossListCount != null && course.crossListCapacity != null} + + {course.crossListCount}/{course.crossListCapacity} + + {/if} + + + + Group {course.crossList} + {#if course.crossListCount != null && course.crossListCapacity != null} + — {course.crossListCount} enrolled across {course.crossListCapacity} shared seats + {/if} + +
{/if} {#if course.waitCapacity > 0}
-

Waitlist

- {course.waitCount} / {course.waitCapacity} +

+ Waitlist +

+ {course.waitCount} / {course.waitCapacity}
{/if}
diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index ad0c561..25fdcfe 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -1,30 +1,65 @@
@@ -36,6 +71,7 @@ Title Instructor Time + Location Seats @@ -43,43 +79,76 @@ {#if loading && courses.length === 0} {#each Array(5) as _} -
+
-
+
+
{/each} {:else if courses.length === 0} - + No courses found. Try adjusting your filters. {:else} {#each courses as course (course.crn)} toggleRow(course.crn)} > - {course.crn} + {course.crn} - {course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""} + {course.subject} {course.courseNumber}{#if course.sequenceNumber}-{course.sequenceNumber}{/if} - {course.title} - {primaryInstructorDisplay(course)} - {timeDisplay(course)} - - {course.enrollment}/{course.maxEnrollment} - {#if course.waitCount > 0} -
WL: {course.waitCount}/{course.waitCapacity}
+ {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} + + {#if expandedCrn === course.crn} - + diff --git a/web/src/lib/components/Pagination.svelte b/web/src/lib/components/Pagination.svelte index d252ed5..c7e32e9 100644 --- a/web/src/lib/components/Pagination.svelte +++ b/web/src/lib/components/Pagination.svelte @@ -1,15 +1,20 @@ {#if totalCount > 0} diff --git a/web/src/lib/components/SearchFilters.svelte b/web/src/lib/components/SearchFilters.svelte index 099a035..faf2039 100644 --- a/web/src/lib/components/SearchFilters.svelte +++ b/web/src/lib/components/SearchFilters.svelte @@ -1,21 +1,21 @@
diff --git a/web/src/lib/course.test.ts b/web/src/lib/course.test.ts index 2efda50..cb8cba0 100644 --- a/web/src/lib/course.test.ts +++ b/web/src/lib/course.test.ts @@ -6,6 +6,10 @@ import { abbreviateInstructor, formatCreditHours, getPrimaryInstructor, + isMeetingTimeTBA, + isTimeTBA, + formatDate, + formatMeetingDaysLong, } from "$lib/course"; import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api"; @@ -45,9 +49,9 @@ describe("formatTime", () => { describe("formatMeetingDays", () => { it("returns MWF for mon/wed/fri", () => { - expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))).toBe( - "MWF" - ); + expect( + formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true })) + ).toBe("MWF"); }); it("returns TR for tue/thu", () => { expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR"); @@ -76,12 +80,20 @@ describe("formatMeetingTime", () => { it("formats a standard meeting time", () => { expect( formatMeetingTime( - makeMeetingTime({ monday: true, wednesday: true, friday: true, begin_time: "0900", end_time: "0950" }) + makeMeetingTime({ + monday: true, + wednesday: true, + friday: true, + begin_time: "0900", + end_time: "0950", + }) ) ).toBe("MWF 9:00 AM–9:50 AM"); }); it("returns TBA when no days", () => { - expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe("TBA"); + expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe( + "TBA" + ); }); it("returns days + TBA when no times", () => { expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA"); @@ -89,7 +101,8 @@ describe("formatMeetingTime", () => { }); describe("abbreviateInstructor", () => { - it("abbreviates standard name", () => expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J.")); + it("abbreviates standard name", () => + expect(abbreviateInstructor("Heaps, John")).toBe("Heaps, J.")); it("handles no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff")); it("handles multiple first names", () => expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M.")); @@ -117,17 +130,99 @@ describe("getPrimaryInstructor", () => { describe("formatCreditHours", () => { it("returns creditHours when set", () => { expect( - formatCreditHours({ creditHours: 3, creditHourLow: null, creditHourHigh: null } as CourseResponse) + formatCreditHours({ + creditHours: 3, + creditHourLow: null, + creditHourHigh: null, + } as CourseResponse) ).toBe("3"); }); it("returns range when variable", () => { expect( - formatCreditHours({ creditHours: null, creditHourLow: 1, creditHourHigh: 3 } as CourseResponse) + formatCreditHours({ + creditHours: null, + creditHourLow: 1, + creditHourHigh: 3, + } as CourseResponse) ).toBe("1–3"); }); it("returns dash when no credit info", () => { expect( - formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse) + formatCreditHours({ + creditHours: null, + creditHourLow: null, + creditHourHigh: null, + } as CourseResponse) ).toBe("—"); }); }); + +describe("isMeetingTimeTBA", () => { + it("returns true when no days set", () => { + expect(isMeetingTimeTBA(makeMeetingTime())).toBe(true); + }); + it("returns false when any day is set", () => { + expect(isMeetingTimeTBA(makeMeetingTime({ monday: true }))).toBe(false); + }); + it("returns false when multiple days set", () => { + expect(isMeetingTimeTBA(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(false); + }); +}); + +describe("isTimeTBA", () => { + it("returns true when begin_time is null", () => { + expect(isTimeTBA(makeMeetingTime())).toBe(true); + }); + it("returns true when begin_time is empty", () => { + expect(isTimeTBA(makeMeetingTime({ begin_time: "" }))).toBe(true); + }); + it("returns true when begin_time is short", () => { + expect(isTimeTBA(makeMeetingTime({ begin_time: "09" }))).toBe(true); + }); + it("returns false when begin_time is valid", () => { + expect(isTimeTBA(makeMeetingTime({ begin_time: "0900" }))).toBe(false); + }); +}); + +describe("formatDate", () => { + it("formats standard date", () => { + expect(formatDate("2024-08-26")).toBe("August 26, 2024"); + }); + it("formats December date", () => { + expect(formatDate("2024-12-12")).toBe("December 12, 2024"); + }); + it("formats January 1st", () => { + expect(formatDate("2026-01-01")).toBe("January 1, 2026"); + }); + it("formats MM/DD/YYYY date", () => { + expect(formatDate("01/20/2026")).toBe("January 20, 2026"); + }); + it("formats MM/DD/YYYY with May", () => { + expect(formatDate("05/13/2026")).toBe("May 13, 2026"); + }); + it("returns original string for invalid input", () => { + expect(formatDate("bad-date")).toBe("bad-date"); + }); +}); + +describe("formatMeetingDaysLong", () => { + it("returns full plural for single day", () => { + expect(formatMeetingDaysLong(makeMeetingTime({ thursday: true }))).toBe("Thursdays"); + }); + it("returns full plural for Monday only", () => { + expect(formatMeetingDaysLong(makeMeetingTime({ monday: true }))).toBe("Mondays"); + }); + it("returns semi-abbreviated for multiple days", () => { + expect( + formatMeetingDaysLong(makeMeetingTime({ monday: true, wednesday: true, friday: true })) + ).toBe("Mon, Wed, Fri"); + }); + it("returns semi-abbreviated for TR", () => { + expect(formatMeetingDaysLong(makeMeetingTime({ tuesday: true, thursday: true }))).toBe( + "Tue, Thur" + ); + }); + it("returns empty string when no days", () => { + expect(formatMeetingDaysLong(makeMeetingTime())).toBe(""); + }); +}); diff --git a/web/src/lib/course.ts b/web/src/lib/course.ts index 0654626..82498a9 100644 --- a/web/src/lib/course.ts +++ b/web/src/lib/course.ts @@ -27,6 +27,23 @@ export function formatMeetingDays(mt: DbMeetingTime): string { .join(""); } +/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */ +export function formatMeetingDaysLong(mt: DbMeetingTime): string { + const days: [boolean, string, string][] = [ + [mt.monday, "Mon", "Mondays"], + [mt.tuesday, "Tue", "Tuesdays"], + [mt.wednesday, "Wed", "Wednesdays"], + [mt.thursday, "Thur", "Thursdays"], + [mt.friday, "Fri", "Fridays"], + [mt.saturday, "Sat", "Saturdays"], + [mt.sunday, "Sun", "Sundays"], + ]; + const active = days.filter(([a]) => a); + if (active.length === 0) return ""; + if (active.length === 1) return active[0][2]; + return active.map(([, short]) => short).join(", "); +} + /** Condensed meeting time: "MWF 9:00 AM–9:50 AM" */ export function formatMeetingTime(mt: DbMeetingTime): string { const days = formatMeetingDays(mt); @@ -47,10 +64,61 @@ export function abbreviateInstructor(name: string): string { } /** Get primary instructor from a course, or first instructor */ -export function getPrimaryInstructor(instructors: InstructorResponse[]): InstructorResponse | undefined { +export function getPrimaryInstructor( + instructors: InstructorResponse[] +): InstructorResponse | undefined { return instructors.find((i) => i.isPrimary) ?? instructors[0]; } +/** Check if a meeting time has no scheduled days */ +export function isMeetingTimeTBA(mt: DbMeetingTime): boolean { + return ( + !mt.monday && + !mt.tuesday && + !mt.wednesday && + !mt.thursday && + !mt.friday && + !mt.saturday && + !mt.sunday + ); +} + +/** Check if a meeting time has no begin/end times */ +export function isTimeTBA(mt: DbMeetingTime): boolean { + return !mt.begin_time || mt.begin_time.length !== 4; +} + +/** Format a date string to "January 20, 2026". Accepts YYYY-MM-DD or MM/DD/YYYY. */ +export function formatDate(dateStr: string): string { + let year: number, month: number, day: number; + if (dateStr.includes("-")) { + [year, month, day] = dateStr.split("-").map(Number); + } else if (dateStr.includes("/")) { + [month, day, year] = dateStr.split("/").map(Number); + } else { + return dateStr; + } + if (!year || !month || !day) return dateStr; + const date = new Date(year, month - 1, day); + return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); +} + +/** Short location string from first meeting time: "MH 2.206" or campus fallback */ +export function formatLocation(course: CourseResponse): string | null { + for (const mt of course.meetingTimes) { + if (mt.building && mt.room) return `${mt.building} ${mt.room}`; + if (mt.building) return mt.building; + } + return course.campus ?? null; +} + +/** Longer location string using building description: "Main Hall 2.206" */ +export function formatLocationLong(mt: DbMeetingTime): string | null { + const name = mt.building_description ?? mt.building; + if (!name) return null; + return mt.room ? `${name} ${mt.room}` : name; +} + /** Format credit hours display */ export function formatCreditHours(course: CourseResponse): string { if (course.creditHours != null) return String(course.creditHours); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 4e94e53..790f277 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,108 +1,102 @@
diff --git a/web/src/routes/health/+page.svelte b/web/src/routes/health/+page.svelte index 3a759ed..8e7dd2a 100644 --- a/web/src/routes/health/+page.svelte +++ b/web/src/routes/health/+page.svelte @@ -45,7 +45,10 @@ type StatusState = | { mode: "error"; lastFetch: Date } | { mode: "timeout"; lastFetch: Date }; -const STATUS_ICONS: Record = { +const STATUS_ICONS: Record< + ServiceStatus | "Unreachable", + { icon: typeof CheckCircle; color: string } +> = { active: { icon: CheckCircle, color: "var(--status-green)" }, connected: { icon: CheckCircle, color: "var(--status-green)" }, starting: { icon: Hourglass, color: "var(--status-orange)" },