feat: sync RMP professor ratings and display in course search interface

This commit is contained in:
2026-01-29 00:26:40 -06:00
parent 5fab8c216a
commit d108a41f91
17 changed files with 1173 additions and 248 deletions
@@ -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';
+7 -4
View File
@@ -98,16 +98,19 @@ 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>)> =
sqlx::query_as(
r#" r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary 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
"#, "#,
+1
View File
@@ -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
View File
@@ -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");
}
}
+1
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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)
}
}
+44
View File
@@ -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({
@@ -71,11 +78,27 @@ impl Scheduler {
async move { async move {
tokio::select! { tokio::select! {
_ = async { _ = 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 if should_scrape_ref
&& let Err(e) = Self::scrape_reference_data(&db_pool, &banner_api, &reference_cache).await && let Err(e) = Self::scrape_reference_data(&db_pool, &banner_api, &reference_cache).await
{ {
error!(error = ?e, "Failed to scrape reference data"); 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 { if let Err(e) = Self::schedule_jobs_impl(&db_pool, &banner_api).await {
error!(error = ?e, "Failed to schedule jobs"); error!(error = ?e, "Failed to schedule jobs");
} }
@@ -90,6 +113,9 @@ impl Scheduler {
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(
+7 -1
View File
@@ -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)| {
InstructorResponse {
banner_id, banner_id,
display_name, display_name,
email, email,
is_primary, is_primary,
rmp_rating,
rmp_num_ratings,
}
}, },
) )
.collect(); .collect();
+186 -40
View File
@@ -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,
} from "$lib/course"; formatDate,
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}>
<Tooltip.Trigger>
<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"
>
{instructor.displayName} {instructor.displayName}
{#if 'rmpRating' in instructor && instructor.rmpRating}
{@const rating = instructor.rmpRating as number}
<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} {#if instructor.isPrimary}
<span class="text-xs bg-card border border-border rounded px-1 py-0.5 ml-1">primary</span> <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}
{#if instructor.email} {#if instructor.email}
<span class="text-xs">{instructor.email}</span> <button
{/if} onclick={(e) => copyEmail(instructor.email!, e)}
</li> class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
{/each} >
</ul> {#if copiedEmail === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else} {:else}
<span class="text-muted-foreground">Staff</span> <Copy class="size-3" />
<span>{instructor.email}</span>
{/if}
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{:else}
<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 class="font-medium text-foreground">
{formatMeetingDaysLong(mt)}
</span> </span>
{/if} {/if}
<div class="text-xs opacity-70">{mt.start_date} {mt.end_date}</div> {#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span class="italic text-muted-foreground">Time TBA</span>
{/if}
</div>
{/if}
{#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)} &ndash; {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}>
<Tooltip.Trigger>
<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} {attr}
</span> </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">
Cross-list
<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"
>
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} {course.crossList}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null} {#if course.crossListCount != null && course.crossListCapacity != null}
({course.crossListCount}/{course.crossListCapacity}) <span class="text-muted-foreground text-xs">
{course.crossListCount}/{course.crossListCapacity}
</span>
{/if} {/if}
</span> </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>
+99 -30
View File
@@ -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 open = openSeats(course);
if (open === 0) return "text-status-red";
if (open <= 5) return "text-yellow-500";
return "text-status-green";
}
function seatsDotColor(course: CourseResponse): string {
const open = openSeats(course);
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); const primary = getPrimaryInstructor(course.instructors);
if (!primary) return "Staff"; if (!primary) return "Staff";
return abbreviateInstructor(primary.displayName); return abbreviateInstructor(primary.displayName);
} }
function timeDisplay(course: CourseResponse): string { function ratingColor(rating: number): string {
if (course.meetingTimes.length === 0) return "TBA"; if (rating >= 4.0) return "text-status-green";
return formatMeetingTime(course.meetingTimes[0]); 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)}&ndash;{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>
+11 -6
View File
@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
let { totalCount, offset, limit, onPageChange }: { let {
totalCount,
offset,
limit,
onPageChange,
}: {
totalCount: number; totalCount: number;
offset: number; offset: number;
limit: number; limit: number;
onPageChange: (newOffset: number) => void; onPageChange: (newOffset: number) => void;
} = $props(); } = $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}
+4 -4
View File
@@ -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
View File
@@ -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 AM9:50 AM"); ).toBe("MWF 9:00 AM9: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("13"); ).toBe("13");
}); });
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
View File
@@ -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 AM9:50 AM" */ /** Condensed meeting time: "MWF 9:00 AM9: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);
+36 -42
View File
@@ -1,32 +1,32 @@
<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) => {
@@ -35,11 +35,11 @@
selectedSubject = ""; selectedSubject = "";
} }
}); });
}); });
// Debounced search // Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | undefined; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
$effect(() => { $effect(() => {
const term = selectedTerm; const term = selectedTerm;
const subject = selectedSubject; const subject = selectedSubject;
const q = query; const q = query;
@@ -52,25 +52,19 @@
}, 300); }, 300);
return () => clearTimeout(searchTimeout); return () => clearTimeout(searchTimeout);
}); });
// Reset offset when filters change (not offset itself) // Reset offset when filters change (not offset itself)
let prevFilters = $state(""); let prevFilters = $state("");
$effect(() => { $effect(() => {
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`; const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
if (prevFilters && key !== prevFilters) { if (prevFilters && key !== prevFilters) {
offset = 0; offset = 0;
} }
prevFilters = key; prevFilters = key;
}); });
async function performSearch( async function performSearch(term: string, subject: string, q: string, open: boolean, off: number) {
term: string,
subject: string,
q: string,
open: boolean,
off: number,
) {
if (!term) return; if (!term) return;
loading = true; loading = true;
error = null; error = null;
@@ -98,11 +92,11 @@
} finally { } finally {
loading = false; 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">
+4 -1
View File
@@ -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)" },