mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
feat: sync RMP professor ratings and display in course search interface
This commit is contained in:
@@ -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';
|
||||||
+12
-9
@@ -98,23 +98,26 @@ pub async fn get_course_by_crn(
|
|||||||
|
|
||||||
/// Get instructors for a course by course ID.
|
/// 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(
|
pub async fn get_course_instructors(
|
||||||
db_pool: &PgPool,
|
db_pool: &PgPool,
|
||||||
course_id: i32,
|
course_id: i32,
|
||||||
) -> Result<Vec<(String, String, Option<String>, bool)>> {
|
) -> Result<Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)>> {
|
||||||
let rows: Vec<(String, String, Option<String>, bool)> = sqlx::query_as(
|
let rows: Vec<(String, String, Option<String>, bool, Option<f32>, Option<i32>)> =
|
||||||
r#"
|
sqlx::query_as(
|
||||||
SELECT i.banner_id, i.display_name, i.email, ci.is_primary
|
r#"
|
||||||
|
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
|
||||||
|
rp.avg_rating, rp.num_ratings
|
||||||
FROM course_instructors ci
|
FROM course_instructors ci
|
||||||
JOIN instructors i ON i.banner_id = ci.instructor_id
|
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
|
WHERE ci.course_id = $1
|
||||||
ORDER BY ci.is_primary DESC, i.display_name
|
ORDER BY ci.is_primary DESC, i.display_name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(db_pool)
|
.fetch_all(db_pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ pub mod batch;
|
|||||||
pub mod courses;
|
pub mod courses;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod reference;
|
pub mod reference;
|
||||||
|
pub mod rmp;
|
||||||
pub mod scrape_jobs;
|
pub mod scrape_jobs;
|
||||||
|
|||||||
+311
@@ -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<i32> = 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<String> = 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<String> = 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<Option<&str>> = deduped
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.department.as_deref())
|
||||||
|
.collect();
|
||||||
|
let avg_ratings: Vec<Option<f32>> = deduped.iter().map(|p| p.avg_rating).collect();
|
||||||
|
let avg_difficulties: Vec<Option<f32>> = deduped.iter().map(|p| p.avg_difficulty).collect();
|
||||||
|
let num_ratings: Vec<i32> = deduped.iter().map(|p| p.num_ratings).collect();
|
||||||
|
let would_take_again_pcts: Vec<Option<f32>> = 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<u64> {
|
||||||
|
// 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<i32>> = 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<i32> = 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<Option<(f32, i32)>> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod data;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod formatter;
|
pub mod formatter;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod rmp;
|
||||||
pub mod scraper;
|
pub mod scraper;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod data;
|
|||||||
mod error;
|
mod error;
|
||||||
mod formatter;
|
mod formatter;
|
||||||
mod logging;
|
mod logging;
|
||||||
|
mod rmp;
|
||||||
mod scraper;
|
mod scraper;
|
||||||
mod services;
|
mod services;
|
||||||
mod signals;
|
mod signals;
|
||||||
|
|||||||
+161
@@ -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<String>,
|
||||||
|
pub avg_rating: Option<f32>,
|
||||||
|
pub avg_difficulty: Option<f32>,
|
||||||
|
pub num_ratings: i32,
|
||||||
|
pub would_take_again_pct: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<RmpProfessor>> {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
let mut cursor: Option<String> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-15
@@ -2,6 +2,7 @@ use crate::banner::{BannerApi, Term};
|
|||||||
use crate::data::models::{ReferenceData, ScrapePriority, TargetType};
|
use crate::data::models::{ReferenceData, ScrapePriority, TargetType};
|
||||||
use crate::data::scrape_jobs;
|
use crate::data::scrape_jobs;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::rmp::RmpClient;
|
||||||
use crate::scraper::jobs::subject::SubjectJob;
|
use crate::scraper::jobs::subject::SubjectJob;
|
||||||
use crate::state::ReferenceCache;
|
use crate::state::ReferenceCache;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -16,6 +17,9 @@ use tracing::{debug, error, info, warn};
|
|||||||
/// How often reference data is re-scraped (6 hours).
|
/// How often reference data is re-scraped (6 hours).
|
||||||
const REFERENCE_DATA_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
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.
|
/// Periodically analyzes data and enqueues prioritized scrape jobs.
|
||||||
pub struct Scheduler {
|
pub struct Scheduler {
|
||||||
db_pool: PgPool,
|
db_pool: PgPool,
|
||||||
@@ -53,6 +57,8 @@ impl Scheduler {
|
|||||||
let mut current_work: Option<(tokio::task::JoinHandle<()>, CancellationToken)> = None;
|
let mut current_work: Option<(tokio::task::JoinHandle<()>, CancellationToken)> = None;
|
||||||
// Scrape reference data immediately on first cycle
|
// Scrape reference data immediately on first cycle
|
||||||
let mut last_ref_scrape = Instant::now() - REFERENCE_DATA_INTERVAL;
|
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 {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -60,6 +66,7 @@ impl Scheduler {
|
|||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
|
|
||||||
let should_scrape_ref = last_ref_scrape.elapsed() >= REFERENCE_DATA_INTERVAL;
|
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.
|
// Spawn work in separate task to allow graceful cancellation during shutdown.
|
||||||
let work_handle = tokio::spawn({
|
let work_handle = tokio::spawn({
|
||||||
@@ -68,28 +75,47 @@ impl Scheduler {
|
|||||||
let cancel_token = cancel_token.clone();
|
let cancel_token = cancel_token.clone();
|
||||||
let reference_cache = self.reference_cache.clone();
|
let reference_cache = self.reference_cache.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = async {
|
_ = async {
|
||||||
if should_scrape_ref
|
// RMP sync is independent of Banner API — run it
|
||||||
&& let Err(e) = Self::scrape_reference_data(&db_pool, &banner_api, &reference_cache).await
|
// concurrently with reference data scraping so it
|
||||||
{
|
// doesn't wait behind rate-limited Banner calls.
|
||||||
error!(error = ?e, "Failed to scrape reference data");
|
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 {
|
if should_scrape_ref {
|
||||||
last_ref_scrape = Instant::now();
|
last_ref_scrape = Instant::now();
|
||||||
}
|
}
|
||||||
|
if should_sync_rmp {
|
||||||
|
last_rmp_sync = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
current_work = Some((work_handle, cancel_token));
|
current_work = Some((work_handle, cancel_token));
|
||||||
next_run = time::Instant::now() + work_interval;
|
next_run = time::Instant::now() + work_interval;
|
||||||
@@ -194,6 +220,24 @@ impl Scheduler {
|
|||||||
Ok(())
|
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.
|
/// Scrape all reference data categories from Banner and upsert to DB, then refresh cache.
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
async fn scrape_reference_data(
|
async fn scrape_reference_data(
|
||||||
|
|||||||
+11
-5
@@ -357,6 +357,8 @@ pub struct InstructorResponse {
|
|||||||
display_name: String,
|
display_name: String,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
is_primary: bool,
|
is_primary: bool,
|
||||||
|
rmp_rating: Option<f32>,
|
||||||
|
rmp_num_ratings: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
@@ -387,11 +389,15 @@ async fn build_course_response(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|(banner_id, display_name, email, is_primary)| InstructorResponse {
|
|(banner_id, display_name, email, is_primary, rmp_rating, rmp_num_ratings)| {
|
||||||
banner_id,
|
InstructorResponse {
|
||||||
display_name,
|
banner_id,
|
||||||
email,
|
display_name,
|
||||||
is_primary,
|
email,
|
||||||
|
is_primary,
|
||||||
|
rmp_rating,
|
||||||
|
rmp_num_ratings,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -1,87 +1,201 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CourseResponse } from "$lib/api";
|
import type { CourseResponse } from "$lib/api";
|
||||||
import {
|
import {
|
||||||
formatTime,
|
formatTime,
|
||||||
formatMeetingDays,
|
formatCreditHours,
|
||||||
formatCreditHours,
|
formatDate,
|
||||||
} from "$lib/course";
|
formatMeetingDaysLong,
|
||||||
|
isMeetingTimeTBA,
|
||||||
|
isTimeTBA,
|
||||||
|
} from "$lib/course";
|
||||||
|
import { Tooltip } from "bits-ui";
|
||||||
|
import { Info, Copy, Check } from "@lucide/svelte";
|
||||||
|
|
||||||
let { course }: { course: CourseResponse } = $props();
|
let { course }: { course: CourseResponse } = $props();
|
||||||
|
|
||||||
|
let copiedEmail: string | null = $state(null);
|
||||||
|
|
||||||
|
async function copyEmail(email: string, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
await navigator.clipboard.writeText(email);
|
||||||
|
copiedEmail = email;
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedEmail = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-muted p-4 text-sm border-b border-border">
|
<div class="bg-muted/60 p-5 text-sm border-b border-border">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
<!-- Instructors -->
|
<!-- Instructors -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Instructors</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
|
Instructors
|
||||||
|
</h4>
|
||||||
{#if course.instructors.length > 0}
|
{#if course.instructors.length > 0}
|
||||||
<ul class="space-y-0.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each course.instructors as instructor}
|
{#each course.instructors as instructor}
|
||||||
<li class="text-muted-foreground">
|
<Tooltip.Root delayDuration={200}>
|
||||||
{instructor.displayName}
|
<Tooltip.Trigger>
|
||||||
{#if instructor.isPrimary}
|
<span
|
||||||
<span class="text-xs bg-card border border-border rounded px-1 py-0.5 ml-1">primary</span>
|
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
|
||||||
{/if}
|
>
|
||||||
{#if instructor.email}
|
{instructor.displayName}
|
||||||
<span class="text-xs"> — {instructor.email}</span>
|
{#if 'rmpRating' in instructor && instructor.rmpRating}
|
||||||
{/if}
|
{@const rating = instructor.rmpRating as number}
|
||||||
</li>
|
<span
|
||||||
|
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}"
|
||||||
|
>{rating.toFixed(1)}★</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="font-medium">{instructor.displayName}</div>
|
||||||
|
{#if instructor.isPrimary}
|
||||||
|
<div class="text-muted-foreground">Primary instructor</div>
|
||||||
|
{/if}
|
||||||
|
{#if 'rmpRating' in instructor && instructor.rmpRating}
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
{(instructor.rmpRating as number).toFixed(1)}/5 ({(instructor as any).rmpNumRatings ?? 0} ratings)
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if instructor.email}
|
||||||
|
<button
|
||||||
|
onclick={(e) => copyEmail(instructor.email!, e)}
|
||||||
|
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if copiedEmail === instructor.email}
|
||||||
|
<Check class="size-3" />
|
||||||
|
<span>Copied!</span>
|
||||||
|
{:else}
|
||||||
|
<Copy class="size-3" />
|
||||||
|
<span>{instructor.email}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground">Staff</span>
|
<span class="text-muted-foreground italic">Staff</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meeting Times -->
|
<!-- Meeting Times -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Meeting Times</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
|
Meeting Times
|
||||||
|
</h4>
|
||||||
{#if course.meetingTimes.length > 0}
|
{#if course.meetingTimes.length > 0}
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-2">
|
||||||
{#each course.meetingTimes as mt}
|
{#each course.meetingTimes as mt}
|
||||||
<li class="text-muted-foreground">
|
<li>
|
||||||
<span class="font-mono">{formatMeetingDays(mt) || "TBA"}</span>
|
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
|
||||||
{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}
|
<span class="italic text-muted-foreground">TBA</span>
|
||||||
{#if mt.building || mt.room}
|
{:else}
|
||||||
<span class="text-xs">
|
<div class="flex items-baseline gap-1.5">
|
||||||
({mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""})
|
{#if !isMeetingTimeTBA(mt)}
|
||||||
</span>
|
<span class="font-medium text-foreground">
|
||||||
|
{formatMeetingDaysLong(mt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if !isTimeTBA(mt)}
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="italic text-muted-foreground">Time TBA</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-xs opacity-70">{mt.start_date} – {mt.end_date}</div>
|
{#if mt.building || mt.room}
|
||||||
|
<div class="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-xs text-muted-foreground/70 mt-0.5">
|
||||||
|
{formatDate(mt.start_date)} – {formatDate(mt.end_date)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground">TBA</span>
|
<span class="italic text-muted-foreground">TBA</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delivery -->
|
<!-- Delivery -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Delivery</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
<span class="text-muted-foreground">
|
<span class="inline-flex items-center gap-1">
|
||||||
|
Delivery
|
||||||
|
<Tooltip.Root delayDuration={100}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Info class="size-3 text-muted-foreground/50" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||||
|
>
|
||||||
|
How the course is taught: in-person, online, hybrid, etc.
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<span class="text-foreground">
|
||||||
{course.instructionalMethod ?? "—"}
|
{course.instructionalMethod ?? "—"}
|
||||||
{#if course.campus}
|
{#if course.campus}
|
||||||
· {course.campus}
|
<span class="text-muted-foreground"> · {course.campus}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Credits -->
|
<!-- Credits -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Credits</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
<span class="text-muted-foreground">{formatCreditHours(course)}</span>
|
Credits
|
||||||
|
</h4>
|
||||||
|
<span class="text-foreground">{formatCreditHours(course)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attributes -->
|
<!-- Attributes -->
|
||||||
{#if course.attributes.length > 0}
|
{#if course.attributes.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Attributes</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
<div class="flex flex-wrap gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
|
Attributes
|
||||||
|
<Tooltip.Root delayDuration={100}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Info class="size-3 text-muted-foreground/50" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||||
|
>
|
||||||
|
Course flags for degree requirements, core curriculum, or special designations
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each course.attributes as attr}
|
{#each course.attributes as attr}
|
||||||
<span class="text-xs bg-card border border-border rounded px-1.5 py-0.5 text-muted-foreground">
|
<Tooltip.Root delayDuration={100}>
|
||||||
{attr}
|
<Tooltip.Trigger>
|
||||||
</span>
|
<span
|
||||||
|
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||||
|
>
|
||||||
|
{attr}
|
||||||
|
</span>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-64"
|
||||||
|
>
|
||||||
|
Course attribute code
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,21 +204,53 @@
|
|||||||
<!-- Cross-list -->
|
<!-- Cross-list -->
|
||||||
{#if course.crossList}
|
{#if course.crossList}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Cross-list</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
<span class="text-muted-foreground">
|
<span class="inline-flex items-center gap-1">
|
||||||
{course.crossList}
|
Cross-list
|
||||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
<Tooltip.Root delayDuration={100}>
|
||||||
({course.crossListCount}/{course.crossListCapacity})
|
<Tooltip.Trigger>
|
||||||
{/if}
|
<Info class="size-3 text-muted-foreground/50" />
|
||||||
</span>
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||||
|
>
|
||||||
|
Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class.
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<Tooltip.Root delayDuration={100}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
|
||||||
|
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
|
||||||
|
{course.crossList}
|
||||||
|
</span>
|
||||||
|
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
{course.crossListCount}/{course.crossListCapacity}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||||
|
>
|
||||||
|
Group <span class="font-mono font-medium">{course.crossList}</span>
|
||||||
|
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||||
|
— {course.crossListCount} enrolled across {course.crossListCapacity} shared seats
|
||||||
|
{/if}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Waitlist -->
|
<!-- Waitlist -->
|
||||||
{#if course.waitCapacity > 0}
|
{#if course.waitCapacity > 0}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-foreground mb-1">Waitlist</h4>
|
<h4 class="text-sm text-foreground mb-2">
|
||||||
<span class="text-muted-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
Waitlist
|
||||||
|
</h4>
|
||||||
|
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CourseResponse } from "$lib/api";
|
import type { CourseResponse } from "$lib/api";
|
||||||
import { abbreviateInstructor, formatMeetingTime, getPrimaryInstructor } from "$lib/course";
|
import {
|
||||||
import CourseDetail from "./CourseDetail.svelte";
|
abbreviateInstructor,
|
||||||
|
formatTime,
|
||||||
|
formatMeetingDays,
|
||||||
|
formatLocation,
|
||||||
|
getPrimaryInstructor,
|
||||||
|
isMeetingTimeTBA,
|
||||||
|
isTimeTBA,
|
||||||
|
} from "$lib/course";
|
||||||
|
import CourseDetail from "./CourseDetail.svelte";
|
||||||
|
|
||||||
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
|
||||||
|
|
||||||
let expandedCrn: string | null = $state(null);
|
let expandedCrn: string | null = $state(null);
|
||||||
|
|
||||||
function toggleRow(crn: string) {
|
function toggleRow(crn: string) {
|
||||||
expandedCrn = expandedCrn === crn ? null : crn;
|
expandedCrn = expandedCrn === crn ? null : crn;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seatsColor(course: CourseResponse): string {
|
function openSeats(course: CourseResponse): number {
|
||||||
return course.enrollment < course.maxEnrollment ? "text-status-green" : "text-status-red";
|
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryInstructorDisplay(course: CourseResponse): string {
|
function seatsColor(course: CourseResponse): string {
|
||||||
const primary = getPrimaryInstructor(course.instructors);
|
const open = openSeats(course);
|
||||||
if (!primary) return "Staff";
|
if (open === 0) return "text-status-red";
|
||||||
return abbreviateInstructor(primary.displayName);
|
if (open <= 5) return "text-yellow-500";
|
||||||
}
|
return "text-status-green";
|
||||||
|
}
|
||||||
|
|
||||||
function timeDisplay(course: CourseResponse): string {
|
function seatsDotColor(course: CourseResponse): string {
|
||||||
if (course.meetingTimes.length === 0) return "TBA";
|
const open = openSeats(course);
|
||||||
return formatMeetingTime(course.meetingTimes[0]);
|
if (open === 0) return "bg-red-500";
|
||||||
}
|
if (open <= 5) return "bg-yellow-500";
|
||||||
|
return "bg-green-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||||
|
const primary = getPrimaryInstructor(course.instructors);
|
||||||
|
if (!primary) return "Staff";
|
||||||
|
return abbreviateInstructor(primary.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingColor(rating: number): string {
|
||||||
|
if (rating >= 4.0) return "text-status-green";
|
||||||
|
if (rating >= 3.0) return "text-yellow-500";
|
||||||
|
return "text-status-red";
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
|
||||||
|
const primary = getPrimaryInstructor(course.instructors);
|
||||||
|
if (!primary?.rmpRating) return null;
|
||||||
|
return { rating: primary.rmpRating, count: primary.rmpNumRatings ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeIsTBA(course: CourseResponse): boolean {
|
||||||
|
if (course.meetingTimes.length === 0) return true;
|
||||||
|
const mt = course.meetingTimes[0];
|
||||||
|
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -36,6 +71,7 @@
|
|||||||
<th class="py-2 px-2 font-medium">Title</th>
|
<th class="py-2 px-2 font-medium">Title</th>
|
||||||
<th class="py-2 px-2 font-medium">Instructor</th>
|
<th class="py-2 px-2 font-medium">Instructor</th>
|
||||||
<th class="py-2 px-2 font-medium">Time</th>
|
<th class="py-2 px-2 font-medium">Time</th>
|
||||||
|
<th class="py-2 px-2 font-medium">Location</th>
|
||||||
<th class="py-2 px-2 font-medium text-right">Seats</th>
|
<th class="py-2 px-2 font-medium text-right">Seats</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -43,43 +79,76 @@
|
|||||||
{#if loading && courses.length === 0}
|
{#if loading && courses.length === 0}
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _}
|
||||||
<tr class="border-b border-border">
|
<tr class="border-b border-border">
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-10 bg-muted rounded animate-pulse"></div></td>
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
|
||||||
<td class="py-2.5 px-2"><div class="h-4 w-12 bg-muted rounded animate-pulse ml-auto"></div></td>
|
<td class="py-2.5 px-2"><div class="h-4 w-16 bg-muted rounded animate-pulse"></div></td>
|
||||||
|
<td class="py-2.5 px-2"><div class="h-4 w-14 bg-muted rounded animate-pulse ml-auto"></div></td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if courses.length === 0}
|
{:else if courses.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="py-12 text-center text-muted-foreground">
|
<td colspan="7" class="py-12 text-center text-muted-foreground">
|
||||||
No courses found. Try adjusting your filters.
|
No courses found. Try adjusting your filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each courses as course (course.crn)}
|
{#each courses as course (course.crn)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
|
||||||
onclick={() => toggleRow(course.crn)}
|
onclick={() => toggleRow(course.crn)}
|
||||||
>
|
>
|
||||||
<td class="py-2 px-2 font-mono">{course.crn}</td>
|
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
|
||||||
<td class="py-2 px-2 whitespace-nowrap">
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
{course.subject} {course.courseNumber}-{course.sequenceNumber ?? ""}
|
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">{course.title}</td>
|
<td class="py-2 px-2 font-medium">{course.title}</td>
|
||||||
<td class="py-2 px-2 whitespace-nowrap">{primaryInstructorDisplay(course)}</td>
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
<td class="py-2 px-2 whitespace-nowrap">{timeDisplay(course)}</td>
|
{primaryInstructorDisplay(course)}
|
||||||
<td class="py-2 px-2 text-right whitespace-nowrap {seatsColor(course)}">
|
{#if primaryRating(course)}
|
||||||
{course.enrollment}/{course.maxEnrollment}
|
{@const r = primaryRating(course)!}
|
||||||
{#if course.waitCount > 0}
|
<span
|
||||||
<div class="text-xs text-muted-foreground">WL: {course.waitCount}/{course.waitCapacity}</div>
|
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
||||||
|
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
|
||||||
|
>{r.rating.toFixed(1)}★</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{#if timeIsTBA(course)}
|
||||||
|
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||||
|
{:else}
|
||||||
|
{@const mt = course.meetingTimes[0]}
|
||||||
|
{#if !isMeetingTimeTBA(mt)}
|
||||||
|
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
|
||||||
|
{" "}
|
||||||
|
{/if}
|
||||||
|
{#if !isTimeTBA(mt)}
|
||||||
|
<span class="text-muted-foreground">{formatTime(mt.begin_time)}–{formatTime(mt.end_time)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted-foreground/60">TBA</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 whitespace-nowrap">
|
||||||
|
{#if formatLocation(course)}
|
||||||
|
<span class="text-muted-foreground">{formatLocation(course)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted-foreground/50">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 text-right whitespace-nowrap">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
|
||||||
|
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
|
||||||
|
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if expandedCrn === course.crn}
|
{#if expandedCrn === course.crn}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="p-0">
|
<td colspan="7" class="p-0">
|
||||||
<CourseDetail {course} />
|
<CourseDetail {course} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { totalCount, offset, limit, onPageChange }: {
|
let {
|
||||||
totalCount: number;
|
totalCount,
|
||||||
offset: number;
|
offset,
|
||||||
limit: number;
|
limit,
|
||||||
onPageChange: (newOffset: number) => void;
|
onPageChange,
|
||||||
} = $props();
|
}: {
|
||||||
|
totalCount: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
onPageChange: (newOffset: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const start = $derived(offset + 1);
|
const start = $derived(offset + 1);
|
||||||
const end = $derived(Math.min(offset + limit, totalCount));
|
const end = $derived(Math.min(offset + limit, totalCount));
|
||||||
const hasPrev = $derived(offset > 0);
|
const hasPrev = $derived(offset > 0);
|
||||||
const hasNext = $derived(offset + limit < totalCount);
|
const hasNext = $derived(offset + limit < totalCount);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalCount > 0}
|
{#if totalCount > 0}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Term, Subject } from "$lib/api";
|
import type { Term, Subject } from "$lib/api";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
terms,
|
terms,
|
||||||
subjects,
|
subjects,
|
||||||
selectedTerm = $bindable(),
|
selectedTerm = $bindable(),
|
||||||
selectedSubject = $bindable(),
|
selectedSubject = $bindable(),
|
||||||
query = $bindable(),
|
query = $bindable(),
|
||||||
openOnly = $bindable(),
|
openOnly = $bindable(),
|
||||||
}: {
|
}: {
|
||||||
terms: Term[];
|
terms: Term[];
|
||||||
subjects: Subject[];
|
subjects: Subject[];
|
||||||
selectedTerm: string;
|
selectedTerm: string;
|
||||||
selectedSubject: string;
|
selectedSubject: string;
|
||||||
query: string;
|
query: string;
|
||||||
openOnly: boolean;
|
openOnly: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
|||||||
+104
-9
@@ -6,6 +6,10 @@ import {
|
|||||||
abbreviateInstructor,
|
abbreviateInstructor,
|
||||||
formatCreditHours,
|
formatCreditHours,
|
||||||
getPrimaryInstructor,
|
getPrimaryInstructor,
|
||||||
|
isMeetingTimeTBA,
|
||||||
|
isTimeTBA,
|
||||||
|
formatDate,
|
||||||
|
formatMeetingDaysLong,
|
||||||
} from "$lib/course";
|
} from "$lib/course";
|
||||||
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
||||||
|
|
||||||
@@ -45,9 +49,9 @@ describe("formatTime", () => {
|
|||||||
|
|
||||||
describe("formatMeetingDays", () => {
|
describe("formatMeetingDays", () => {
|
||||||
it("returns MWF for mon/wed/fri", () => {
|
it("returns MWF for mon/wed/fri", () => {
|
||||||
expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))).toBe(
|
expect(
|
||||||
"MWF"
|
formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
|
||||||
);
|
).toBe("MWF");
|
||||||
});
|
});
|
||||||
it("returns TR for tue/thu", () => {
|
it("returns TR for tue/thu", () => {
|
||||||
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR");
|
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR");
|
||||||
@@ -76,12 +80,20 @@ describe("formatMeetingTime", () => {
|
|||||||
it("formats a standard meeting time", () => {
|
it("formats a standard meeting time", () => {
|
||||||
expect(
|
expect(
|
||||||
formatMeetingTime(
|
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");
|
).toBe("MWF 9:00 AM–9:50 AM");
|
||||||
});
|
});
|
||||||
it("returns TBA when no days", () => {
|
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", () => {
|
it("returns days + TBA when no times", () => {
|
||||||
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
|
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA");
|
||||||
@@ -89,7 +101,8 @@ describe("formatMeetingTime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("abbreviateInstructor", () => {
|
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 no comma", () => expect(abbreviateInstructor("Staff")).toBe("Staff"));
|
||||||
it("handles multiple first names", () =>
|
it("handles multiple first names", () =>
|
||||||
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
|
expect(abbreviateInstructor("Smith, Mary Jane")).toBe("Smith, M."));
|
||||||
@@ -117,17 +130,99 @@ describe("getPrimaryInstructor", () => {
|
|||||||
describe("formatCreditHours", () => {
|
describe("formatCreditHours", () => {
|
||||||
it("returns creditHours when set", () => {
|
it("returns creditHours when set", () => {
|
||||||
expect(
|
expect(
|
||||||
formatCreditHours({ creditHours: 3, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
formatCreditHours({
|
||||||
|
creditHours: 3,
|
||||||
|
creditHourLow: null,
|
||||||
|
creditHourHigh: null,
|
||||||
|
} as CourseResponse)
|
||||||
).toBe("3");
|
).toBe("3");
|
||||||
});
|
});
|
||||||
it("returns range when variable", () => {
|
it("returns range when variable", () => {
|
||||||
expect(
|
expect(
|
||||||
formatCreditHours({ creditHours: null, creditHourLow: 1, creditHourHigh: 3 } as CourseResponse)
|
formatCreditHours({
|
||||||
|
creditHours: null,
|
||||||
|
creditHourLow: 1,
|
||||||
|
creditHourHigh: 3,
|
||||||
|
} as CourseResponse)
|
||||||
).toBe("1–3");
|
).toBe("1–3");
|
||||||
});
|
});
|
||||||
it("returns dash when no credit info", () => {
|
it("returns dash when no credit info", () => {
|
||||||
expect(
|
expect(
|
||||||
formatCreditHours({ creditHours: null, creditHourLow: null, creditHourHigh: null } as CourseResponse)
|
formatCreditHours({
|
||||||
|
creditHours: null,
|
||||||
|
creditHourLow: null,
|
||||||
|
creditHourHigh: null,
|
||||||
|
} as CourseResponse)
|
||||||
).toBe("—");
|
).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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+69
-1
@@ -27,6 +27,23 @@ export function formatMeetingDays(mt: DbMeetingTime): string {
|
|||||||
.join("");
|
.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" */
|
/** Condensed meeting time: "MWF 9:00 AM–9:50 AM" */
|
||||||
export function formatMeetingTime(mt: DbMeetingTime): string {
|
export function formatMeetingTime(mt: DbMeetingTime): string {
|
||||||
const days = formatMeetingDays(mt);
|
const days = formatMeetingDays(mt);
|
||||||
@@ -47,10 +64,61 @@ export function abbreviateInstructor(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get primary instructor from a course, or first instructor */
|
/** 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];
|
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 */
|
/** Format credit hours display */
|
||||||
export function formatCreditHours(course: CourseResponse): string {
|
export function formatCreditHours(course: CourseResponse): string {
|
||||||
if (course.creditHours != null) return String(course.creditHours);
|
if (course.creditHours != null) return String(course.creditHours);
|
||||||
|
|||||||
+90
-96
@@ -1,108 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||||
import Pagination from "$lib/components/Pagination.svelte";
|
import Pagination from "$lib/components/Pagination.svelte";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// Read initial state from URL params (intentionally captured once)
|
// Read initial state from URL params (intentionally captured once)
|
||||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||||
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
||||||
let query = $state(initialParams.get("q") ?? "");
|
let query = $state(initialParams.get("q") ?? "");
|
||||||
let openOnly = $state(initialParams.get("open") === "true");
|
let openOnly = $state(initialParams.get("open") === "true");
|
||||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
let subjects: Subject[] = $state([]);
|
let subjects: Subject[] = $state([]);
|
||||||
let searchResult: SearchResponse | null = $state(null);
|
let searchResult: SearchResponse | null = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
// Fetch subjects when term changes
|
// Fetch subjects when term changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const term = selectedTerm;
|
const term = selectedTerm;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
client.getSubjects(term).then((s) => {
|
client.getSubjects(term).then((s) => {
|
||||||
subjects = s;
|
subjects = s;
|
||||||
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
||||||
selectedSubject = "";
|
selectedSubject = "";
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
$effect(() => {
|
||||||
|
const term = selectedTerm;
|
||||||
|
const subject = selectedSubject;
|
||||||
|
const q = query;
|
||||||
|
const open = openOnly;
|
||||||
|
const off = offset;
|
||||||
|
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
performSearch(term, subject, q, open, off);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(searchTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset offset when filters change (not offset itself)
|
||||||
|
let prevFilters = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
||||||
|
if (prevFilters && key !== prevFilters) {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
prevFilters = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function performSearch(term: string, subject: string, q: string, open: boolean, off: number) {
|
||||||
|
if (!term) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Sync URL
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("term", term);
|
||||||
|
if (subject) params.set("subject", subject);
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
if (open) params.set("open", "true");
|
||||||
|
if (off > 0) params.set("offset", String(off));
|
||||||
|
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
searchResult = await client.searchCourses({
|
||||||
|
term,
|
||||||
|
subject: subject || undefined,
|
||||||
|
q: q || undefined,
|
||||||
|
open_only: open || undefined,
|
||||||
|
limit,
|
||||||
|
offset: off,
|
||||||
});
|
});
|
||||||
});
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Search failed";
|
||||||
// Debounced search
|
} finally {
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
loading = false;
|
||||||
$effect(() => {
|
|
||||||
const term = selectedTerm;
|
|
||||||
const subject = selectedSubject;
|
|
||||||
const q = query;
|
|
||||||
const open = openOnly;
|
|
||||||
const off = offset;
|
|
||||||
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
performSearch(term, subject, q, open, off);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(searchTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset offset when filters change (not offset itself)
|
|
||||||
let prevFilters = $state("");
|
|
||||||
$effect(() => {
|
|
||||||
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
|
||||||
if (prevFilters && key !== prevFilters) {
|
|
||||||
offset = 0;
|
|
||||||
}
|
|
||||||
prevFilters = key;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function performSearch(
|
|
||||||
term: string,
|
|
||||||
subject: string,
|
|
||||||
q: string,
|
|
||||||
open: boolean,
|
|
||||||
off: number,
|
|
||||||
) {
|
|
||||||
if (!term) return;
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
// Sync URL
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("term", term);
|
|
||||||
if (subject) params.set("subject", subject);
|
|
||||||
if (q) params.set("q", q);
|
|
||||||
if (open) params.set("open", "true");
|
|
||||||
if (off > 0) params.set("offset", String(off));
|
|
||||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
searchResult = await client.searchCourses({
|
|
||||||
term,
|
|
||||||
subject: subject || undefined,
|
|
||||||
q: q || undefined,
|
|
||||||
open_only: open || undefined,
|
|
||||||
limit,
|
|
||||||
offset: off,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : "Search failed";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handlePageChange(newOffset: number) {
|
function handlePageChange(newOffset: number) {
|
||||||
offset = newOffset;
|
offset = newOffset;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col items-center p-5">
|
<div class="min-h-screen flex flex-col items-center p-5">
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ type StatusState =
|
|||||||
| { mode: "error"; lastFetch: Date }
|
| { mode: "error"; lastFetch: Date }
|
||||||
| { mode: "timeout"; lastFetch: Date };
|
| { mode: "timeout"; lastFetch: Date };
|
||||||
|
|
||||||
const STATUS_ICONS: Record<ServiceStatus | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
const STATUS_ICONS: Record<
|
||||||
|
ServiceStatus | "Unreachable",
|
||||||
|
{ icon: typeof CheckCircle; color: string }
|
||||||
|
> = {
|
||||||
active: { icon: CheckCircle, color: "var(--status-green)" },
|
active: { icon: CheckCircle, color: "var(--status-green)" },
|
||||||
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
||||||
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
||||||
|
|||||||
Reference in New Issue
Block a user